Convert a PDF into a markdown document, handling images and latex.
---
format: typebulb/v1
name: PDF To Markdown
---
**code.tsx**
```tsx
import React, { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import * as pdfjs from "pdfjs-dist";
import { PDFDocument, StandardFonts, degrees, rgb, type PDFPage } from "pdf-lib";
import { marked } from "marked";
import { zipSync, strToU8, type Zippable } from "fflate";
import katex from "katex";
pdfjs.GlobalWorkerOptions.workerSrc = tb.proxy(
`https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`
);
// The PDF's text can't smuggle HTML into the preview: marked's HTML tokens (a "<think>" tag in a
// prompt) render as the text they are. Escaping the source instead would double up inside code,
// where marked escapes for itself.
const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
// The sub and sup tags the bulb writes for scripts are the one HTML that renders.
marked.use({ renderer: { html: ({ text }: { text: string }) => /^<\/?su[bp]>$/.test(text) ? text : escapeHtml(text) } });
interface TextItem {
str: string;
x: number;
y: number;
width: number;
height: number;
font: FontInfo;
}
// What a font's name says about it (ext: a math extension font, cmex10's big operators and
// delimiters). pdf.js names a text item's font by an internal id; the font itself, with its real
// name, reaches the page's object store once the operator list is fetched. The MathTime names are
// anchored: loose, a SmText optical size (Source Serif's) reads as MTEX, and a body set in it as math.
interface FontInfo { bold: boolean; italic: boolean; mono: boolean; math: boolean; ext: boolean }
const PLAIN_FONT: FontInfo = { bold: false, italic: false, mono: false, math: false, ext: false };
const fontOf = (name: string): FontInfo => ({
bold: /Bold|Black|Heavy|Semibold|Demibold|Medi|CMBX|CMB\d|CMSSBX/i.test(name),
italic: /Ital|Obli|CMTI|CMSL/i.test(name),
mono: /Mono|NimbusMon|Cour|Cursor|Typewriter|CMTT|Consol|Menlo|Inconsolata|LetterGothic|txtt|SFTT/i.test(name),
math: /CMMI|CMSY|CMEX|CMBSY|MSAM|MSBM|Math|rsfs|stmry|wasy|txmi|txsy|txex|pxmi|pxsy|pxex|\bMT(?:MI|SY|EX)|eufm|eurm|esint|\bSymbol/i.test(name),
ext: /CMEX|txex|pxex/i.test(name),
});
// An extension font keeps its big operators at letter codes, which pdf.js hands through as they
// are ("P" for a text-style sum); the common ones read as themselves, and its wide accents as the
// text fonts' (set before their letter: "ˆa"). A tall radical is pieces: a foot (u) under
// extensions (v) under a hook (w), one sign; the biggest fixed one (t) and the Bigg close paren
// (!) read as themselves too.
const EXT = new Map(Object.entries({
P: "∑", X: "∑", Q: "∏", Y: "∏", R: "∫", Z: "∫", S: "⋃", "[": "⋃", T: "⋂", "\\": "⋂", U: "⊎", "]": "⊎",
V: "⋀", "^": "⋀", W: "⋁", _: "⋁", "`": "∐", a: "∐", i: "∮", j: "∮", k: "⨀", l: "⨀", m: "⨁", n: "⨁", o: "⨂",
p: "√", q: "√", r: "√", s: "√", t: "√", u: "√", v: "", w: "", "!": ")", b: "ˆ", c: "ˆ", d: "ˆ", e: "˜", f: "˜", g: "˜",
}));
function pageFonts(page: any, tc: any, names: Set<string>): Map<string, FontInfo> {
const fonts = new Map<string, FontInfo>();
for (const it of tc.items) {
if (!it.fontName || fonts.has(it.fontName)) continue;
let name = "";
try { name = page.commonObjs.has(it.fontName) ? page.commonObjs.get(it.fontName)?.name || "" : ""; } catch {}
names.add(name.replace(/^[A-Z]{6}\+/, "") || "(unnamed)");
fonts.set(it.fontName, fontOf(name));
}
return fonts;
}
// The frame a page reads in. LaTeX sets a landscape page (a wide table) as portrait with /Rotate 90
// and its text turned in the page's own space, which is where pdf.js reports it; the conversion
// works in the frame instead. rot: the rotation (pdf.js's, clockwise degrees) that sets the page's
// dominant text upright, by characters. to/from: the maps between page space and the frame, y up,
// origin at the frame's corner. view: the frame's box.
interface Frame { rot: number; to: number[]; from: number[]; view: number[] }
const pt = (m: number[], x: number, y: number): [number, number] =>
[m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
function pageFrame(items: any[], view: number[]): Frame {
const chars = [0, 0, 0, 0]; // text reading right, up, left, down
for (const it of items) {
const [a, b] = it.transform ?? [1, 0];
const q = Math.abs(a) >= Math.abs(b) ? (a >= 0 ? 0 : 2) : (b > 0 ? 1 : 3);
chars[q] += (it.str ?? "").trim().length;
}
const [x0, y0, x1, y1] = view;
const I = [1, 0, 0, 1, 0, 0];
switch (chars.indexOf(Math.max(...chars))) {
case 1: return { rot: 90, to: [0, -1, 1, 0, -y0, x1], from: [0, 1, -1, 0, x1, y0], view: [0, 0, y1 - y0, x1 - x0] };
case 2: return { rot: 180, to: [-1, 0, 0, -1, x0 + x1, y0 + y1], from: [-1, 0, 0, -1, x0 + x1, y0 + y1], view };
case 3: return { rot: 270, to: [0, 1, -1, 0, y1, -x0], from: [0, -1, 1, 0, x0, y1], view: [0, 0, y1 - y0, x1 - x0] };
default: return { rot: 0, to: I, from: I, view };
}
}
interface Line {
y: number;
height: number;
items: TextItem[];
// 0: the page's one column, or its left; 1: its right; -1: across both (a title, a wide table).
col: number;
}
// The page's text in its frame, and the text set across the frame: margin decoration (arXiv's
// stamp, an upright page number on a turned page), out of the flow.
function extractItems(textContent: any, fonts: Map<string, FontInfo>, frame: Frame): { items: TextItem[]; margin: string[] } {
const items: TextItem[] = [];
const margin: string[] = [];
for (const it of textContent.items) {
if (typeof it.str !== "string") continue;
const t = it.transform;
const { to } = frame;
if (Math.abs(to[1] * t[0] + to[3] * t[1]) > Math.abs(to[0] * t[0] + to[2] * t[1])) { margin.push(it.str); continue; }
const [x, y] = pt(to, t[4], t[5]);
const font = fonts.get(it.fontName) ?? PLAIN_FONT;
const height = it.height || Math.abs(t[3]) || 12;
// A glyph with no Unicode (a math font's, an icon font's) leaks its raw code: below 0x20 it
// makes grep call the file binary, in 0x80-0x9f it renders as a broken box.
let str = it.str.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
if (font.ext) str = [...str].map(c => EXT.get(c) ?? c).join("");
items.push({
str,
x,
// An extension font's glyph hangs below its origin (depth, no height), which TeX therefore
// sets high, up by the line above; the glyph's middle is where it reads.
y: font.ext ? y - height * 0.5 : y,
width: it.width || 0,
height,
font,
});
}
return { items, margin };
}
// Cluster items into lines by y-coordinate. PDF y-axis points up,
// so lines are sorted top-of-page first (largest y first).
function groupIntoLines(items: TextItem[], col = 0): Line[] {
if (!items.length) return [];
const sorted = [...items].sort((a, b) => b.y - a.y || a.x - b.x);
const lines: Line[] = [];
for (const it of sorted) {
const cur = lines[lines.length - 1];
if (cur && Math.abs(cur.y - it.y) < it.height * 0.5) {
cur.items.push(it);
} else {
lines.push({ y: it.y, height: it.height, items: [it], col });
}
}
const settle = (ln: Line) => {
ln.items.sort((a, b) => a.x - b.x);
ln.height = dominantHeight(ln.items);
// The baseline is the body's: a superscript sorts first as the highest item, and would set it.
ln.y = ln.items.find(it => it.height === ln.height && it.str.trim())?.y ?? ln.y;
};
lines.forEach(settle);
// A line of floaters hugging a body line belongs to it: subscripts and an operator's limits set
// lower than the clustering reaches (a superscript above is caught: the body item comes after
// it), and, body-sized by their fonts' metrics, an accent set above its letter and a big
// operator or a radical set off the baseline. Hugging means within reach of the host's text
// too: a subscript sits by its letter, not by a label across the column.
const floats = (it: TextItem, nb: Line) =>
it.height < nb.height * 0.8 || it.font.ext || /^[\^~ˆ˜¯˙¨´`˘ˇ˚⃗√]*$/.test(it.str.trim());
const near = (it: TextItem, nb: Line) => {
const e = extent(nb);
return it.x + it.width >= e.x - nb.height * 1.5 && it.x <= e.right + nb.height * 1.5;
};
for (let k = lines.length - 1; k >= 0; k--) {
const ln = lines[k];
const host = [lines[k + 1], lines[k - 1]].find(nb => nb && ln.items.every(it => floats(it, nb) && near(it, nb))
&& ln.y >= nb.y - nb.height * 0.5 && ln.y <= nb.y + nb.height * 0.8);
if (!host) continue;
host.items.push(...ln.items);
settle(host);
lines.splice(k, 1);
}
return lines;
}
// Characters set at each height, and their total.
function heightWeights(items: TextItem[]): [Map<number, number>, number] {
const w = new Map<number, number>();
let total = 0;
for (const it of items) {
const n = it.str.trim().length;
if (!n) continue;
w.set(it.height, (w.get(it.height) || 0) + n);
total += n;
}
return [w, total];
}
// Median height weighted by text length: body text sets the baseline even when subscripts
// and footnotes outnumber it as items (LaTeX splits math into hundreds of tiny ones).
function dominantHeight(items: TextItem[]): number {
const [w, total] = heightWeights(items);
if (!total) return items.length ? Math.max(...items.map(i => i.height)) : 0;
let acc = 0;
for (const [h, n] of [...w.entries()].sort((a, b) => a[0] - b[0])) {
acc += n;
if (acc * 2 >= total) return h;
}
return 0;
}
// The body size of some lines: the largest height setting a sixth or more of their prose. Larger
// text is a title or a heading, and short; smaller text (footnotes, captions, references, an
// appendix of transcripts) can outrun the body; and a figure's numbers or a table's cells are no
// prose, so don't count. Zero with no text.
function bodyHeight(lines: Line[]): number {
const prose = lines.filter(isProse);
const [w, total] = heightWeights((prose.length ? prose : lines).flatMap(ln => ln.items));
return Math.max(0, ...[...w].filter(([, n]) => n * 6 >= total).map(([h]) => h));
}
// A horizontal gap wider than this fraction of the line height means the PDF
// omitted the space character between items; we restore it on join.
const SPACE_GAP = 0.2;
interface Col { x: number; right: number; text: string }
// Split a line at large horizontal gaps. >=2 runs => candidate table row. Whitespace items
// are skipped: pdf.js encodes a gap as one wide " " item, which would bridge the columns.
// A trailing page-number-like item splits on a smaller gap, for contents pages.
function lineColumns(line: Line): Col[] {
const cols: Col[] = [];
const gapThreshold = line.height * 1.1;
const items = line.items.filter(it => it.str.trim());
items.forEach((it, i) => {
const last = cols[cols.length - 1];
const thr = i === items.length - 1 && /^\s*\d{1,4}\s*$/.test(it.str) ? line.height * 0.8 : gapThreshold;
if (last && it.x - last.right < thr) {
if (it.x - last.right > line.height * SPACE_GAP && !last.text.endsWith(" ") && !it.str.startsWith(" "))
last.text += " ";
last.text += it.str;
last.right = Math.max(last.right, it.x + it.width);
} else {
cols.push({ x: it.x, right: it.x + it.width, text: it.str });
}
});
return cols
.map(c => ({ ...c, text: c.text.trim() }))
.filter(c => c.text);
}
// Math fonts are italic by design: their letters are variables, not emphasis. A typewriter run in
// prose (a URL, an identifier) is code.
const styleOf = (f: FontInfo): Style =>
f.math ? "" : f.mono ? "c" : (f.bold && f.italic) ? "bi" : f.bold ? "b" : f.italic ? "i" : "";
type Style = "" | "b" | "i" | "bi" | "c";
const tag = (s: Style) =>
s === "b" ? "**" : s === "i" ? "*" : s === "bi" ? "***" : s === "c" ? "`" : "";
// A run smaller than its line and off its baseline is a superscript (raised) or a subscript (lowered).
type Script = "" | "sup" | "sub";
const scriptOf = (it: TextItem, ln: Line): Script => {
if (!it.str.trim() || it.height >= ln.height * 0.85) return "";
const dy = it.y - ln.y;
return dy > ln.height * 0.2 ? "sup" : dy < -ln.height * 0.08 ? "sub" : "";
};
// Unicode has forms for digits, a few signs, and most letters; a run they cover reads inline
// ("Fan¹", "xᵢ"), and so does a run of signs alone (a footnote mark, a significance star, a prime,
// the comma between affiliations: whatever glyph the font hands over, "Hwang¶" reads unraised).
// Anything else takes <sup> and <sub>, which every markdown viewer renders; pandoc's ^…^ and ~…~
// render nowhere else, and GFM reads a lone tilde as strikethrough.
const scriptMap = (plain: string, forms: string) =>
new Map([...plain].map((c, i): [string, string] => [c, [...forms][i]]));
const SUP = scriptMap("0123456789+-−=()abcdefghijklmnoprstuvwxyz", "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁻⁼⁽⁾ᵃᵇᶜᵈᵉᶠᵍʰⁱʲᵏˡᵐⁿᵒᵖʳˢᵗᵘᵛʷˣʸᶻ");
const SUB = scriptMap("0123456789+-−=()aehijklmnoprstuvx", "₀₁₂₃₄₅₆₇₈₉₊₋₋₌₍₎ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓ");
// Each item's text with its script applied: a run of same-script items is one Unicode string or
// one tagged span.
function scriptedText(line: Line, scripts: Script[]): string[] {
const out = line.items.map(it => it.str);
for (let i = 0; i < out.length;) {
const s = scripts[i];
if (!s) { i++; continue; }
let j = i + 1;
while (j < out.length && scripts[j] === s) j++;
const map = s === "sup" ? SUP : SUB;
const run = out.slice(i, j).join("");
if ([...run].every(c => map.has(c) || /[\p{P}\p{S}\s]/u.test(c))) {
for (let k = i; k < j; k++) out[k] = [...out[k]].map(c => map.get(c) ?? c).join("");
} else {
out[i] = `<${s}>` + out[i];
out[j - 1] += `</${s}>`;
}
i = j;
}
return out;
}
// Limits stacked over an operator interleave by x ("ᵢᵐ₌₁"): within a run of scripted items the
// subscripts go first, then the superscripts, as LaTeX writes them (∑_{i=1}^m).
function orderScripts(items: TextItem[], scripts: Script[]): [TextItem[], Script[]] {
const out: TextItem[] = [], outS: Script[] = [];
for (let i = 0; i < items.length;) {
if (!scripts[i]) { out.push(items[i]); outS.push(scripts[i]); i++; continue; }
let j = i + 1;
while (j < items.length && (scripts[j] || !items[j].str.trim())) j++;
for (const s of ["sub", "sup", ""] as Script[]) for (let k = i; k < j; k++) if (scripts[k] === s) { out.push(items[k]); outS.push(s); }
i = j;
}
return [out, outS];
}
// Concatenate items, wrapping bold/italic runs. Whitespace-only items and scripts (a footnote mark
// on a bold name) inherit the previous style so we don't get "**foo** **bar**" splits. Plain: the
// raw text, for a code block, with no style or script marks; otherwise a literal asterisk outside
// code is escaped, so it can't read as one.
function joinLineText(line: Line, plain = false): string {
const [items, scripts] = orderScripts(line.items, line.items.map(it => scriptOf(it, line)));
const texts = plain ? items.map(it => it.str)
: scriptedText({ ...line, items }, scripts).map((s, i) => items[i].font.mono ? s : s.replace(/\*/g, "\\*"));
let out = "";
let cur: Style = "";
let prevRight = -Infinity;
// A closing mark goes before trailing whitespace, the only place markdown accepts it.
const close = () => { const tr = out.match(/\s*$/)![0]; out = out.slice(0, out.length - tr.length) + tag(cur) + tr; };
items.forEach((it, i) => {
if (!it.str) return;
const str = texts[i];
const needSpace = out !== "" && it.x - prevRight > line.height * SPACE_GAP
&& !out.endsWith(" ") && !str.startsWith(" ");
const s: Style = plain || !it.str.trim() || scripts[i] ? cur : styleOf(it.font);
if (s !== cur) {
if (cur) close();
if (needSpace) out += " ";
const lead = str.match(/^\s*/)?.[0] ?? "";
out += lead;
if (s) out += tag(s);
out += str.slice(lead.length);
cur = s;
} else {
if (needSpace) out += " ";
out += str;
}
prevRight = it.x + it.width;
});
if (cur) close();
return out;
}
// A heading is text larger than the body: the document's, so a title's level holds on a page of
// transcripts set small, or the page's own where that is larger.
function classifyHeading(line: Line, L: Layout): "h1" | "h2" | "h3" | null {
const h = line.height, medH = Math.max(L.medH, L.docH);
if (h > medH * 1.7) return "h1";
if (h > medH * 1.35) return "h2";
if (h > medH * 1.1) return "h3";
return null;
}
function getListMarker(text: string): { kind: "ul" | "ol"; rest: string } | null {
const ul = text.match(/^[•‣◦⁃·▪●○]\s+(.+)/);
if (ul) return { kind: "ul", rest: ul[1] };
const dash = text.match(/^[-*]\s+(.+)/);
if (dash) return { kind: "ul", rest: dash[1] };
const ol = text.match(/^\d+[.)]\s+(.+)/);
if (ol) return { kind: "ol", rest: ol[1] };
return null;
}
interface Table { rows: string[][]; end: number }
// A table starts at `start` when 2+ rows share the header line's column count and x-positions.
// A line with fewer columns continues the row above when each sits inside the header's column
// (a wrapped cell); a full line does when rows are padded apart and the cell above ran to the
// table's edge. Null when no table starts here.
function detectTable(lines: Line[], start: number): Table | null {
const head = lines[start];
if (!head) return null;
const cols0 = lineColumns(head);
const n = cols0.length;
if (n < 2) return null;
const h = head.height;
const tol = h * 5;
// The nearest header column, if within tolerance (columns can sit closer than the tolerance).
const colOf = (c: Col) => {
let best = -1, d = tol;
cols0.forEach((hc, i) => { const di = Math.abs(c.x - hc.x); if (di <= d) { d = di; best = i; } });
return best;
};
type Entry = { cols: Col[]; idx: number[]; gap: number };
const run: Entry[] = [{ cols: cols0, idx: cols0.map((_, i) => i), gap: 0 }];
let end = start + 1;
for (; end < lines.length; end++) {
const ln = lines[end];
const cols = lineColumns(ln);
const gap = lines[end - 1].y - ln.y;
if (!cols.length || gap > h * 4) break;
const idx = cols.map(colOf);
if (idx.some((ci, k) => ci < 0 || (k > 0 && ci <= idx[k - 1]))) break;
if (cols.length < n) {
const fits = gap <= h * 1.6 && cols.every((c, k) => idx[k] === n - 1 || c.right <= cols0[idx[k] + 1].x);
if (!fits) break;
}
run.push({ cols, idx, gap });
}
const padded = run.some(e => e.gap > h * 1.8);
const right = Math.max(...run.map(e => e.cols[e.cols.length - 1].right));
const rows: string[][] = [];
let prev: Entry | null = null;
for (const e of run) {
const wrapped = prev && prev.cols.length === n && prev.cols[n - 1].right >= right - (right - cols0[n - 1].x) * 0.12;
const cont = prev && (e.cols.length < n || (padded && e.gap <= h * 1.45 && wrapped));
if (!cont) rows.push(new Array(n).fill(""));
const row = rows[rows.length - 1];
e.cols.forEach((c, k) => { row[e.idx[k]] = row[e.idx[k]] ? row[e.idx[k]] + " " + c.text : c.text; });
prev = e;
}
return rows.length < 2 ? null : { rows, end };
}
// A contents run: three or more lines, each a title and a page number, where most carry a dot
// leader or the column is headed Contents. A section number alone is no trigger (a ranked table's
// rows start and end in numbers); it sets an entry's depth, and lets a chapter line without a
// leader into a run the leaders justify. A title wrapped short of its number continues on the
// line under it. Rendered as one nested list, each entry ending in its page; the first entry opens
// at the margin whatever its depth (a list continuing from the page before can't begin nested).
interface Contents { md: string; end: number }
const CONTENTS_HEAD = /^(?:table of )?contents$|^list of (?:figures|tables|algorithms)$/i;
const LEADER = /(?:\s*\.){3,}\s*$/;
// What comes off a title: a leader, or the two spaced dots a long title leaves room for.
const TRAIL = /(?:\s*\.){3,}\s*$|(?:\s\.)+\s*$/;
const SECTION = /^(?:\d+(?:\.\d+)*|[A-Z](?:\.\d+)+)\.?(?=\s)/;
function contentsRun(lines: Line[], start: number, medH: number, headed: boolean): Contents | null {
type Entry = { x: number; text: string; page: string; sec: string | null };
const pageOf = (cols: Col[]) => cols.length >= 2 && /^\d{1,4}$/.test(cols[cols.length - 1].text) ? cols[cols.length - 1].text : null;
const titleOf = (cols: Col[]) => cols.slice(0, -1).map(c => c.text).join(" ");
const entries: Entry[] = [];
let end = start;
for (; end < lines.length; end++) {
const ln = lines[end];
if (end > start && lines[end - 1].y - ln.y > ln.height * 4) break;
const cols = lineColumns(ln);
if (!cols.length) break;
let page = pageOf(cols);
let text = page ? titleOf(cols) : cols.map(c => c.text).join(" ");
if (!page) {
const nx = lines[end + 1];
const nc = nx ? lineColumns(nx) : [];
const np = pageOf(nc);
if (!np || !SECTION.test(text) || SECTION.test(titleOf(nc)) || ln.y - nx.y > ln.height * 1.6) break;
page = np; text = `${text} ${titleOf(nc)}`; end++;
}
const sec = text.match(SECTION)?.[0] ?? null;
if (!text.replace(TRAIL, "").trim() || !(sec || LEADER.test(text) || headed)) break;
entries.push({ x: cols[0].x, text, page, sec });
}
if (entries.length < 3 || !(headed || entries.filter(e => LEADER.test(e.text)).length * 2 >= entries.length)) return null;
const x0 = Math.min(...entries.map(e => e.x));
const depths = entries.map(e => e.sec ? e.sec.replace(/\.$/, "").split(".").length : 1 + Math.round((e.x - x0) / (medH * 2)));
const d0 = Math.min(...depths);
const md = entries.map((e, k) => `${k ? " ".repeat(Math.min(depths[k] - d0, 3)) : ""}- ${e.text.replace(TRAIL, "").trim()} … ${e.page}\n`).join("");
return { md, end };
}
function renderTable(rows: string[][]): string {
const esc = (s: string) => s.replace(/\|/g, "\\|");
let out = "| " + rows[0].map(esc).join(" | ") + " |\n";
out += "|" + " --- |".repeat(rows[0].length) + "\n";
for (const r of rows.slice(1)) out += "| " + r.map(esc).join(" | ") + " |\n";
return out;
}
// Layout facts: the body font size (the document's, or a page's own where it sets little in it),
// the document's own, and the text column's extent, measured document-wide on the pages set the
// document's way (rot); a page turned the other way (a wide table) has no known column.
interface Layout { medH: number; docH: number; textWidth: number; left: number; right: number; rot: number }
interface Page { lines: Line[]; frame: Frame }
// A line's text extent (a whitespace item, which pdf.js can set wide, has no ink).
const extent = (ln: Line) => {
const its = ln.items.filter(it => it.str.trim());
const xs = its.length ? its : ln.items;
return { x: Math.min(...xs.map(it => it.x)), right: Math.max(...xs.map(it => it.x + it.width)) };
};
function measureLayout(pages: Page[]): Layout {
const medH = bodyHeight(pages.flatMap(p => p.lines)) || 12;
const count = (r: number) => pages.filter(p => p.frame.rot === r).length;
const rot = [0, 90, 180, 270].reduce((a, b) => count(b) > count(a) ? b : a);
const body = pages.filter(p => p.frame.rot === rot).flatMap(p => p.lines)
.filter(ln => ln.items.length && ln.height >= medH * 0.9).map(extent);
const spans = body.map(e => e.right - e.x).sort((a, b) => a - b);
// The 90th percentile, not the max: one stray line parked at a huge off-page coordinate
// would otherwise define the column for the whole document.
const textWidth = spans.length ? spans[Math.floor((spans.length - 1) * 0.9)] : 0;
const wide = body.filter(e => { const s = e.right - e.x; return s >= textWidth * 0.5 && s <= textWidth * 1.5; });
return {
medH,
docH: medH,
textWidth,
left: Math.min(Infinity, ...wide.map(e => e.x)),
right: Math.max(-Infinity, ...wide.map(e => e.right)),
rot,
};
}
// Body text is body-sized or wide; anything else (axis labels, legends) may live inside a figure.
const isBody = (ln: Line, L: Layout) => {
const e = extent(ln);
return ln.height >= L.medH * 0.9 || e.right - e.x >= L.textWidth * 0.35;
};
const span = (its: TextItem[]) => Math.max(...its.map(it => it.x + it.width)) - Math.min(...its.map(it => it.x));
// What a line has either side of x: the text on each (an item across x counts on both), the gap
// between the two (negative then, NaN with a side empty), and whether each side is prose: one run
// with no gap of a column separation inside, running most of its half of the page's text extent
// [left, right]. A table row or a chart's tick labels have the gaps; a wrapped paragraph doesn't.
function acrossX(ln: Line, x: number, m: number, left: number, right: number) {
const l: TextItem[] = [], r: TextItem[] = [];
for (const it of ln.items) {
if (!it.str.trim()) continue;
if (it.x < x) l.push(it);
if (it.x + it.width > x) r.push(it);
}
const gap = l.length && r.length ? Math.min(...r.map(it => it.x)) - Math.max(...l.map(it => it.x + it.width)) : NaN;
const prose = (its: TextItem[], w: number) =>
its.length > 0 && its.every((it, i) => !i || it.x - (its[i - 1].x + its[i - 1].width) < m * 0.9) && span(its) >= w * 0.45;
return { l, r, both: l.length > 0 && r.length > 0, gap, lProse: prose(l, x - left), rProse: prose(r, right - x) };
}
// A line of two columns' prose: prose either side of x, a column separation apart.
const proseAcross = (ln: Line, x: number, m: number, left: number, right: number) => {
const a = acrossX(ln, x, m, left, right);
return a.gap >= m * 0.9 && a.lProse && a.rProse;
};
// The text extent of a page's body lines (infinite when there are none).
function bodyExtent(lines: Line[], m: number): { left: number; right: number } {
const ext = lines.filter(ln => ln.height >= m * 0.9 && ln.items.some(it => it.str.trim())).map(extent);
return { left: Math.min(...ext.map(e => e.x)), right: Math.max(...ext.map(e => e.right)) };
}
// A two-column page's gutter: the x that the most body lines break across as prose, when they are
// a good share of the page's body lines and it sits mid-page. Null when the page reads as one column.
function findGutter(lines: Line[], m: number): number | null {
const body = lines.filter(ln => ln.height >= m * 0.9 && ln.items.some(it => it.str.trim()));
if (body.length < 4) return null;
const { left, right } = bodyExtent(body, m);
// Candidates: the middle of each line's gaps of a column separation's width.
const cands = new Set<number>();
for (const ln of body) {
let r = -Infinity;
for (const it of ln.items) {
if (!it.str.trim()) continue;
if (isFinite(r) && it.x - r >= m * 0.9) cands.add(Math.round((r + it.x) / 2));
r = Math.max(r, it.x + it.width);
}
}
const w = right - left;
let best: number | null = null;
let most = Math.max(4, Math.ceil(body.length * 0.35)) - 1;
for (const c of cands) {
if (c < left + w * 0.3 || c > right - w * 0.3) continue;
const n = body.filter(ln => proseAcross(ln, c, m, left, right)).length;
if (n > most) { most = n; best = c; }
}
if (best === null) return null;
// The gutter's middle: the median, over the lines that break there, of their gap's middle.
const x = best;
const mids = body.filter(ln => proseAcross(ln, x, m, left, right))
.map(ln => { const { l, r } = acrossX(ln, x, m, left, right); return (Math.max(...l.map(it => it.x + it.width)) + Math.min(...r.map(it => it.x))) / 2; })
.sort((a, b) => a - b);
return mids[mids.length >> 1];
}
// The page's lines cut at the gutter: a line with a gap across it is a line in each column, a line
// with text across it spans both (a title, an abstract), and the rest lie in one. A table across
// the gutter keeps its rows whole: rows with text on both sides and prose on neither (two columns'
// prose detects as a two-column table, and is cut; a column's table beside prose is cut too). With
// the lines, each column's layout: its edges, from its body lines.
function columnize(lines: Line[], gutter: number | null, L: Layout): { lines: Line[]; layoutOf: (col: number) => Layout } {
if (gutter === null) return { lines, layoutOf: () => L };
const m = L.medH;
const { left, right } = bodyExtent(lines, m);
const whole = new Set<Line>();
for (let i = 0; i < lines.length;) {
const t = detectTable(lines, i);
if (!t) { i++; continue; }
const rows = lines.slice(i, t.end);
const as = rows.map(ln => acrossX(ln, gutter, m, left, right));
const across = as.filter(a => a.both).length;
const prosy = as.filter(a => a.lProse || a.rProse).length;
if (across * 2 >= rows.length && prosy * 4 <= rows.length) for (const ln of rows) whole.add(ln);
i = t.end;
}
const cols: TextItem[][] = [[], []];
const out: Line[] = [];
for (const ln of lines) {
const a = acrossX(ln, gutter, m, left, right);
if (whole.has(ln) || (a.both && a.gap < m * 0.9)) { out.push({ ...ln, col: -1 }); continue; }
for (const it of ln.items) cols[it.x + it.width / 2 < gutter ? 0 : 1].push(it);
}
out.push(...groupIntoLines(cols[0], 0), ...groupIntoLines(cols[1], 1));
out.sort((a, b) => b.y - a.y || a.col - b.col);
const layouts = [0, 1].map(c => {
const e = bodyExtent(out.filter(ln => ln.col === c), m);
return isFinite(e.left) ? { ...L, left: e.left, right: e.right, textWidth: e.right - e.left } : L;
});
return { lines: out, layoutOf: col => (col < 0 ? L : layouts[col]) };
}
interface Band { top: number; bottom: number }
// What sits in a page's flow besides its text (a figure's reference, a code block), placed by the
// band it occupies and the column it sits in.
interface Block extends Band { md: string; col: number }
interface Box { x0: number; y0: number; x1: number; y1: number }
// What the page draws, as evidence of what a region is: an image; a rule or a rect (a fraction
// bar, a frame, a table's line: furniture); or a curve or polyline, weighted by its coordinate count.
interface Graphic extends Box { weight: number; rect: boolean; image: boolean }
const mul = (c: number[], t: number[]) => [
c[0] * t[0] + c[2] * t[1], c[1] * t[0] + c[3] * t[1],
c[0] * t[2] + c[2] * t[3], c[1] * t[2] + c[3] * t[3],
c[0] * t[4] + c[2] * t[5] + c[4], c[1] * t[4] + c[3] * t[5] + c[5],
];
function boxUnder(m: number[], x0: number, y0: number, x1: number, y1: number): Box {
const xs: number[] = [], ys: number[] = [];
for (const [x, y] of [[x0, y0], [x1, y0], [x0, y1], [x1, y1]]) {
xs.push(m[0] * x + m[2] * y + m[4]);
ys.push(m[1] * x + m[3] * y + m[5]);
}
return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) };
}
// The part of a inside b (all of a with no clip), or null. A rule's box has no width or no height,
// and keeps it through the cut.
const intersect = (a: Box, b: Box | null): Box | null => {
if (!b) return a;
const r = { x0: Math.max(a.x0, b.x0), y0: Math.max(a.y0, b.y0), x1: Math.min(a.x1, b.x1), y1: Math.min(a.y1, b.y1) };
const has = (lo: number, hi: number, flat: boolean) => hi > lo || (hi === lo && flat);
return has(r.x0, r.x1, a.x0 === a.x1) && has(r.y0, r.y1, a.y0 === a.y1) ? r : null;
};
// Where the page draws, in its frame: every image and path as a box. Read off the operator list,
// tracking the CTM and the clip through save/restore/transform and form XObjects (an image is
// often placed far larger than its clip shows).
async function pageGraphics(page: any, frame: Frame): Promise<Graphic[]> {
const { fnArray, argsArray } = await page.getOperatorList();
const O = pdfjs.OPS;
let ctm = frame.to;
let clip: Box | null = null;
let pendingClip = false;
let lastPath: Box | null = null;
const applyClip = (box: Box) => { clip = intersect(box, clip) ?? { x0: 0, y0: 0, x1: 0, y1: 0 }; };
const stack: { ctm: number[]; clip: Box | null }[] = [];
const out: Graphic[] = [];
const add = (box: Box, weight: number, rect: boolean, image: boolean) => {
const b = intersect(box, clip);
if (b) out.push({ ...b, weight, rect, image });
};
fnArray.forEach((fn: number, i: number) => {
const a = argsArray[i];
if (fn === O.save) stack.push({ ctm, clip });
else if (fn === O.restore) ({ ctm, clip } = stack.pop() ?? { ctm, clip });
else if (fn === O.transform) ctm = mul(ctm, a);
else if (fn === O.paintFormXObjectBegin) {
// A form's bbox clips its content.
stack.push({ ctm, clip });
if (a[0]) ctm = mul(ctm, a[0]);
if (a[1]?.length === 4) applyClip(boxUnder(ctm, a[1][0], a[1][1], a[1][2], a[1][3]));
}
else if (fn === O.paintFormXObjectEnd) ({ ctm, clip } = stack.pop() ?? { ctm, clip });
// The clip op comes after the path it applies to (or, in some streams, before it).
else if (fn === O.clip || fn === O.eoClip) { if (lastPath) applyClip(lastPath); else pendingClip = true; }
else if (fn === O.paintImageXObject || fn === O.paintInlineImageXObject || fn === O.paintImageMaskXObject)
add(boxUnder(ctm, 0, 0, 1, 1), 0, false, true);
else if (fn === O.constructPath && a[2]?.length === 4 && isFinite(a[2][0])) {
const box = boxUnder(ctm, a[2][0], a[2][1], a[2][2], a[2][3]);
if (a[0] !== O.endPath) {
// A rule or a rect is up to 13 numbers; a curve or polyline runs on.
const len = (a[1] || []).reduce((s: number, p: any) => s + (p?.length || 0), 0);
add(box, len, len <= 13, false);
}
if (pendingClip) { applyClip(box); pendingClip = false; }
lastPath = box;
}
if (fn !== O.constructPath) lastPath = null;
});
return out;
}
const union = (a: Box, b: Box): Box =>
({ x0: Math.min(a.x0, b.x0), y0: Math.min(a.y0, b.y0), x1: Math.max(a.x1, b.x1), y1: Math.max(a.y1, b.y1) });
// The page's render, and the frame it reads in: a box in the frame as pixels of the render (y
// down), and back. Both maps turn by right angles, so a box maps by its corners.
interface Render { canvas: HTMLCanvasElement; vp: any; frame: Frame }
const boxOf = (a: number[], b: number[]): Box =>
({ x0: Math.min(a[0], b[0]), y0: Math.min(a[1], b[1]), x1: Math.max(a[0], b[0]), y1: Math.max(a[1], b[1]) });
function toPx(R: Render, b: Box): Box {
const p = boxUnder(R.frame.from, b.x0, b.y0, b.x1, b.y1);
return boxOf(R.vp.convertToViewportPoint(p.x0, p.y0), R.vp.convertToViewportPoint(p.x1, p.y1));
}
function fromPx(R: Render, b: Box): Box {
const [ax, ay] = R.vp.convertToPdfPoint(b.x0, b.y0);
const [bx, by] = R.vp.convertToPdfPoint(b.x1, b.y1);
return boxOf(pt(R.frame.to, ax, ay), pt(R.frame.to, bx, by));
}
// The render's ink, a point per cell (pixels over the scale, y down): a cell is inked when any of
// its pixels is darker than paper. The flow text is cleared: it bounds the page's other content
// rather than being it.
interface Ink { cells: Uint8Array; w: number; h: number }
function inkMask(R: Render, flow: Box[]): Ink {
const { canvas, vp } = R;
const s = vp.scale;
const w = Math.ceil(canvas.width / s), h = Math.ceil(canvas.height / s);
const cells = new Uint8Array(w * h);
const px = canvas.getContext("2d")!.getImageData(0, 0, canvas.width, canvas.height).data;
for (let y = 0, i = 0; y < canvas.height; y++) {
const row = Math.floor(y / s) * w;
for (let x = 0; x < canvas.width; x++, i += 4)
if (px[i] < 235 || px[i + 1] < 235 || px[i + 2] < 235) cells[row + Math.floor(x / s)] = 1;
}
for (const b of flow) {
const p = toPx(R, b);
const x0 = Math.max(0, Math.floor(p.x0 / s)), x1 = Math.min(w, Math.ceil(p.x1 / s));
for (let y = Math.max(0, Math.floor(p.y0 / s)); y < Math.min(h, Math.ceil(p.y1 / s)); y++) cells.fill(0, y * w + x0, y * w + x1);
}
return { cells, w, h };
}
// The ink clustered into regions: cells of c points, joined across a cell (a fraction's bar and
// its numerator, an axis and its ticks). A region's box is its ink's, exactly, in the frame.
function inkRegions(R: Render, ink: Ink, c: number): Box[] {
const { cells, w, h } = ink;
const cw = Math.ceil(w / c), ch = Math.ceil(h / c);
const coarse = new Uint8Array(cw * ch);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (cells[y * w + x]) coarse[Math.floor(y / c) * cw + Math.floor(x / c)] = 1;
const seen = new Uint8Array(cw * ch);
const s = R.vp.scale;
const out: Box[] = [];
for (let start = 0; start < coarse.length; start++) {
if (!coarse[start] || seen[start]) continue;
const box = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
const stack = [start];
seen[start] = 1;
while (stack.length) {
const k = stack.pop()!;
const cx = k % cw, cy = (k - cx) / cw;
for (let y = cy * c; y < Math.min(h, (cy + 1) * c); y++)
for (let x = cx * c; x < Math.min(w, (cx + 1) * c); x++)
if (cells[y * w + x]) { box.x0 = Math.min(box.x0, x); box.x1 = Math.max(box.x1, x + 1); box.y0 = Math.min(box.y0, y); box.y1 = Math.max(box.y1, y + 1); }
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
const nx = cx + dx, ny = cy + dy, k2 = ny * cw + nx;
if (nx >= 0 && ny >= 0 && nx < cw && ny < ch && coarse[k2] && !seen[k2]) { seen[k2] = 1; stack.push(k2); }
}
}
out.push(fromPx(R, { x0: box.x0 * s, y0: box.y0 * s, x1: box.x1 * s, y1: box.y1 * s }));
}
return out;
}
// A line set in a box's band: its baseline no lower than a quarter line under the box, its
// x-height under the top. A lead-in word over a tall display sits above its band, whatever its
// delimiter's tip clears.
const inBand = (ln: Line, r: Box) => ln.y >= r.y0 - ln.height * 0.25 && ln.y + ln.height * 0.4 <= r.y1;
// The share of a line's text sitting in the region: its baseline band inside, with a little
// slack sideways for labels. By characters, because lines are clustered by y across the page: a
// paragraph wrapped beside a figure shares a line with the figure's labels.
function ownedShare(ln: Line, r: Box, m: number): number {
// An extension-font line (a big operator on a line of its own) reads at its middle and hangs far
// either way; a radical of any size is set from its top, and hangs too.
const ext = ln.items.every(it => it.font.ext || !it.str.trim() || (it.font.math && /^√+$/.test(it.str.trim())));
if (ext ? ln.y < r.y0 - ln.height || ln.y > r.y1 + ln.height : !inBand(ln, r)) return 0;
let inside = 0, total = 0;
for (const it of ln.items) {
const k = it.str.trim().length;
if (!k) continue;
total += k;
const cx = it.x + it.width / 2;
if (cx >= r.x0 - m && cx <= r.x1 + m) inside += k;
}
return total ? inside / total : 0;
}
// How much of the region's width a line runs across (a line beside a wrapped figure: none).
function overlapX(ln: Line, r: Box): number {
const e = extent(ln);
return Math.max(0, Math.min(e.right, r.x1) - Math.max(e.x, r.x0)) / (r.x1 - r.x0 || 1);
}
// A line of prose: four words or more in text fonts (a figure's labels and an axis title have
// fewer; an equation's variables are words in a math font), in one run: a row of panel titles
// has the words, and the gaps.
const isProse = (ln: Line) => ln.items.reduce((k, it) => k + (it.font.math ? 0 : (it.str.match(/[A-Za-z]{3,}/g) || []).length), 0) >= 4;
const isProseRun = (ln: Line) => isProse(ln) && lineColumns(ln).length === 1;
// Crops the rect out of the page render, with the holes (text from outside the region that reaches
// into the crop: a caption's ascenders) painted white, then trimmed to its ink. Null when blank.
async function cropFigure(canvas: HTMLCanvasElement, vp: any, rect: Box, holes: Box[] = []): Promise<Uint8Array | null> {
const [ax, ay] = vp.convertToViewportPoint(rect.x0, rect.y1);
const [bx, by] = vp.convertToViewportPoint(rect.x1, rect.y0);
const x0 = Math.max(0, Math.floor(Math.min(ax, bx)));
const y0 = Math.max(0, Math.floor(Math.min(ay, by)));
const w = Math.min(canvas.width, Math.ceil(Math.max(ax, bx))) - x0;
const h = Math.min(canvas.height, Math.ceil(Math.max(ay, by))) - y0;
if (w < 2 || h < 2) return null;
const cut = document.createElement("canvas");
cut.width = w;
cut.height = h;
const g = cut.getContext("2d")!;
g.drawImage(canvas, x0, y0, w, h, 0, 0, w, h);
g.fillStyle = "#fff";
for (const hole of holes) {
const [hx0, hy0] = vp.convertToViewportPoint(hole.x0, hole.y1);
const [hx1, hy1] = vp.convertToViewportPoint(hole.x1, hole.y0);
g.fillRect(Math.min(hx0, hx1) - x0, Math.min(hy0, hy1) - y0, Math.abs(hx1 - hx0), Math.abs(hy1 - hy0));
}
const px = g.getImageData(0, 0, w, h).data;
let minX = w, minY = h, maxX = -1, maxY = -1;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
if (px[i] < 235 || px[i + 1] < 235 || px[i + 2] < 235) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX < 0 || maxY - minY < 4) return null;
const m = Math.round(vp.scale * 4);
const cx = Math.max(0, minX - m), cy = Math.max(0, minY - m);
const cw = Math.min(w, maxX + m + 1) - cx, ch = Math.min(h, maxY + m + 1) - cy;
const out = document.createElement("canvas");
out.width = cw;
out.height = ch;
out.getContext("2d")!.drawImage(cut, cx, cy, cw, ch, 0, 0, cw, ch);
const blob = await new Promise<Blob>((res, rej) =>
out.toBlob(b => (b ? res(b) : rej(new Error("PNG encode failed"))), "image/png"));
return new Uint8Array(await blob.arrayBuffer());
}
// A figure's file: what the document, the folder, and the zip carry.
interface FigureFile { file: string; png: Uint8Array }
// Pages render at three pixels a point (216 DPI): a crop's pixel size over the scale is its size
// on the page, which the preview sizes it by.
const RENDER_SCALE = 3;
const pngDim = (png: Uint8Array) => {
const v = new DataView(png.buffer, png.byteOffset, png.byteLength);
return { w: v.getUint32(16), h: v.getUint32(20) };
};
// A figure while its page is assembled: the file, its crop (where it sits in the flow), its
// caption, and for a display equation its numbers and its text layer (its LaTeX is checked
// against it).
interface Figure extends Band, FigureFile { alt: string; col: number; eq?: { tags: string[]; text: string } }
const CAPTION = /^(Figure|Fig\.?|Table|Chart|Exhibit|Plate)\s*[\dA-Z]+\s*[.:\-–—]/i;
const FIG_CAPTION = /^(Figure|Fig\.?|Chart|Exhibit|Plate)\s*[\dA-Z]+\s*[.:\-–—]/i;
const cleanAlt = (s: string) => s.replace(/[\[\]()]/g, "").replace(/\s+/g, " ").trim().slice(0, 140);
// A line's text under the box (a wrapped figure's caption shares its line with the paragraph
// beside it), plain.
function textUnder(ln: Line, r: Box, m: number): string {
const items = ln.items.filter(it => { const cx = it.x + it.width / 2; return cx >= r.x0 - m && cx <= r.x1 + m; });
return joinLineText({ ...ln, items }, true).trim();
}
// A line's caption text: the text under the box when that reads as one (a wrapped figure's
// caption shares its line with the paragraph beside it), else the whole line when it runs across
// the box (a caption set wider than its figure, from the margin). Empty for no caption.
function captionText(ln: Line, r: Box, m: number): string {
const under = textUnder(ln, r, m);
if (CAPTION.test(under)) return under;
const all = joinLineText(ln, true).trim();
return overlapX(ln, r) >= 0.5 && CAPTION.test(all) ? all : "";
}
const isCaption = (ln: Line, r: Box, m: number) => captionText(ln, r, m) !== "";
// The caption: the first caption-shaped line inside the crop or within a few lines under it (an
// axis title and the caption skip sit between the ink and the caption), else the one right above.
function captionFor(lines: Line[], L: Layout, rect: Box, fallback: string): string {
const m = L.medH;
const hit = lines.find(ln => ln.y <= rect.y1 && ln.y >= rect.y0 - m * 6 && isCaption(ln, rect, m))
?? [...lines].reverse().find(ln => ln.y > rect.y1 && ln.y < rect.y1 + m * 1.5 && isCaption(ln, rect, m));
return hit ? cleanAlt(captionText(hit, rect, m)) : fallback;
}
const hashes = (h: "h1" | "h2" | "h3") => (h === "h1" ? "#" : h === "h2" ? "##" : "###");
// A wrapped line joins its predecessor; a line-break hyphen after a letter goes ("informa-" + "tion").
const joinWrapped = (acc: string, next: string) =>
/[a-zA-Z]-$/.test(acc) ? acc.slice(0, -1) + next : acc + " " + next;
// A line set in the middle of the column, clear of both edges by the same margin.
function centered(ln: Line, L: Layout): boolean {
if (!isFinite(L.left) || !isFinite(L.right)) return false;
const e = extent(ln);
const lg = e.x - L.left, rg = L.right - e.right;
return lg > L.medH * 2 && rg > L.medH * 2 && Math.abs(lg - rg) <= L.medH * 0.5;
}
// A section title set in body-sized capitals ("ABSTRACT", "1 INTRODUCTION", "2.1 SETUP"): a short
// line of capitals with room above it. A numbered one nests by its depth.
function capsHeading(text: string, ln: Line, prev: Line | undefined, L: Layout): "h2" | "h3" | null {
const m = text.replace(/\*/g, "").match(/^(?:(\d+(?:\.\d+)*)\.?\s+)?([A-Z][A-Z0-9 ,:&'()-]*[A-Z0-9)])$/);
if (!m || (m[2].match(/[A-Z]/g) || []).length < 4) return null;
const e = extent(ln);
if (e.right - e.x > L.textWidth * 0.7) return null;
if (prev && prev.y - ln.y <= ln.height * 1.3) return null;
return m[1]?.includes(".") ? "h3" : "h2";
}
const isPageNumber = (ln: Line) => /^\d{1,4}$/.test(joinLineText(ln, true).trim());
// A heading is emphasis enough: its style marks go, every unescaped asterisk (a title's footnote
// star stays).
const unstyled = (s: string) => s.replace(/(?<!\\)\*+/g, "");
// The share of the items' characters in items the test accepts.
function itemShare(items: TextItem[], test: (it: TextItem) => boolean): number {
let hit = 0, total = 0;
for (const it of items) { const k = it.str.trim().length; total += k; if (test(it)) hit += k; }
return total ? hit / total : 0;
}
const fontShare = (ln: Line, test: (f: FontInfo) => boolean) => itemShare(ln.items, it => test(it.font));
// A math glyph: set in a math font, or a lone italic letter in a text font (mathpazo and mathptmx
// set math italics from the text face, so a variable is the text's italic).
const isMathGlyph = (it: TextItem) => it.font.math || (it.font.italic && /^\s*\p{L}\s*$/u.test(it.str));
const isMono = (ln: Line) => fontShare(ln, f => f.mono) >= 0.6;
// A listing is two or more monospace lines a line or two apart; a lone one (a URL) is prose.
function monoRunEnd(lines: Line[], i: number): number {
let j = i + 1;
while (j < lines.length && isMono(lines[j]) && lines[j - 1].y - lines[j].y <= lines[j - 1].height * 2.5) j++;
return j;
}
const startsListing = (lines: Line[], i: number) => isMono(lines[i]) && monoRunEnd(lines, i) - i >= 2;
// A display equation's line: math-font glyphs among its body-sized ones (its digits and brackets
// come from the text fonts; its subscripts don't count) with a variable among them (a table's
// stars and ticks are math glyphs too), set where prose isn't: centered in the column, well clear
// of its left edge, or tagged at its right edge; and few words. A row continuing an equation
// needs only its math share and a few words (a wide equation's main line sits off center, pushed
// by its number; a fraction's numerator can be a text-font "1"), and one ending in the equation's
// tag belongs whatever it holds.
// An equation's tag: its number, or a word ((CR), (IC)), in parentheses.
const TAG = String.raw`\((\d+[a-z]?|[A-Z][A-Za-z]{0,5}\d?)\)`;
const TAG_END = new RegExp(TAG + "$"), TAG_ALONE = new RegExp("^" + TAG + "$");
const isEquationLine = (ln: Line, L: Layout, cont = false) => {
const text = joinLineText(ln, true).trim();
const e = extent(ln);
const m = L.medH;
const numbered = TAG_END.test(text) && e.right >= L.right - m;
const body = ln.items.filter(it => it.height >= ln.height * 0.85 && it.str.trim());
const share = itemShare(body.length ? body : ln.items, isMathGlyph);
const words = ln.items.filter(it => !it.font.math).reduce((k, it) => k + (it.str.match(/[A-Za-z]{3,}/g) || []).length, 0);
if (cont) return numbered || (share >= 0.15 && words <= 6);
if (share < 0.15 || !ln.items.some(it => isMathGlyph(it) && /[\p{L}\p{N}]/u.test(it.str))) return false;
const lg = e.x - L.left, rg = L.right - e.right;
const set = isFinite(L.left) && isFinite(L.right)
? lg > m * 2 || (lg > m * 0.5 && Math.abs(lg - rg) <= m) || numbered
: share >= 0.5;
return set && words <= 3;
};
// Lines that stop short of the block's edge where no paragraph ends: a listing, not prose. A
// paragraph ends on the block's last line, on an item ("1.", "(ii)") or before one, where the
// next line starts elsewhere (an indent, the edge after a list's wrapped lines), and before what
// was cut out of the block (a display's lines are the ones missing from `kept`).
function ragged(kept: Line[], all: Line[]): boolean {
const sorted = [...all].sort((a, b) => b.y - a.y);
const right = Math.max(...kept.map(ln => extent(ln).right));
const width = right - Math.min(...kept.map(ln => extent(ln).x));
const item = (ln: Line) => { const t = joinLineText(ln, true).trim(); return !!getListMarker(t) || /^\((?:[ivx]+|[a-z]|\d+)\)\s/i.test(t); };
const ends = (i: number) => i === sorted.length - 1 || !kept.includes(sorted[i + 1]) || item(sorted[i]) || item(sorted[i + 1])
|| Math.abs(extent(sorted[i + 1]).x - extent(sorted[i]).x) > sorted[i].height * 0.5;
const short = sorted.filter((ln, i) => kept.includes(ln) && !ends(i) && extent(ln).right < right - width * 0.15).length;
return short >= Math.max(1, (kept.length - 1) * 0.3);
}
// A block's lines as a fenced code block, one per line, indented by where each starts.
function fenced(lines: Line[]): string {
const sorted = [...lines].sort((a, b) => b.y - a.y);
const startX = (ln: Line) => (ln.items.find(it => it.str.trim()) ?? ln.items[0]).x;
const left = Math.min(...sorted.map(startX));
const rows = sorted.map(ln => " ".repeat(Math.round((startX(ln) - left) / (ln.height * 0.6))) + joinLineText(ln, true).trim());
return "```\n" + rows.join("\n") + "\n```\n\n";
}
// A line the text keeps: prose, a heading, a list item, a listing's line, a caption, a page
// number, or a body-sized line in text fonts starting at the column's edge (a paragraph's last
// line, a "Therefore,"). The rest is a label, a tick, a limit: content of what the page draws.
function isFlow(ln: Line, L: Layout, edge: number): boolean {
const text = joinLineText(ln, true).trim();
if (!text) return false;
// A lone letter, however large, is a panel's label, not a heading.
if (/^\(?[A-Za-z]\)?\.?$/.test(text)) return false;
if (isProseRun(ln) || isMono(ln) || CAPTION.test(text) || isPageNumber(ln) || getListMarker(text)) return true;
if (classifyHeading(ln, L) || capsHeading(text, ln, undefined, L)) return true;
return ln.height >= L.medH * 0.9 && fontShare(ln, f => f.math) < 0.5 && extent(ln).x <= edge + L.medH;
}
// What a page holds besides its text: the figures and display equations cut out of its render,
// with the lines they take with them, and the boxed listings kept as code blocks.
interface Cuts { figures: Figure[]; blocks: Block[]; drop: Set<Line> }
interface PageIn { page: any; p: number; n: number; pad: (i: number) => string; lines: Line[]; frame: Frame; L: Layout; layoutOf: (col: number) => Layout; gutter: number | null }
type Kind = "figure" | "equation" | "stray" | "text";
interface Region extends Box { kind: Kind; note: string; why: string; cap: boolean; took: number }
const at = (r: Box) => `${r.x0.toFixed(0)}-${r.x1.toFixed(0)}×${r.y0.toFixed(0)}-${r.y1.toFixed(0)}`;
// The ink decides where a region is; the text layer and the operator list decide what it is. The
// flow text is cleared from the ink first, so a region is what sits between the text, and its box
// is its ink's, complete: nothing estimated from a glyph's metrics or a path's placement to fall
// short. Each region's kind comes from what lies in it: a caption, an image, or real drawing makes
// a figure; mostly flow text makes a text box, which stays text; math makes an equation; ten drawn
// elements with labels among them make a diagram. A figure then takes what sits within three line
// heights of it with no flow line between (an axis title, a diagram's rows, a subfigure's label),
// and an equation takes the next equation (a derivation's rows).
async function cutPage(P: PageIn, canvas: HTMLCanvasElement, onProgress: (s: string) => void): Promise<Cuts> {
const { page, p, n, pad, lines, frame, L, layoutOf, gutter } = P;
const m = L.medH;
const view = frame.view;
const pageArea = (view[2] - view[0]) * (view[3] - view[1]);
const drop = new Set<Line>();
const blocks: Block[] = [];
const figures: Figure[] = [];
// A box's column: the one it sits in, or both when it crosses the gutter (a wide figure).
const colOf = (b: Box) => gutter === null ? 0 : b.x0 < gutter - m && b.x1 > gutter + m ? -1 : (b.x0 + b.x1) / 2 < gutter ? 0 : 1;
const inCol = (ln: Line, r: Box) => ln.col < 0 || colOf(r) < 0 || ln.col === colOf(r);
// A rule running most of the page and past the text column is furniture, not content: a margin
// or column rule, a page frame's side, the line under a running head. It leaves the ink with the
// flow text, or the ink would join whatever it runs past into one region. A table's rule or a
// chart's axis runs within the column.
const furniture = (g: Graphic) => g.rect && (
(g.y1 - g.y0 >= (view[3] - view[1]) * 0.5 && g.x1 - g.x0 < m * 0.3
&& (g.x0 < L.left || g.x1 > L.right || (gutter !== null && Math.abs((g.x0 + g.x1) / 2 - gutter) < m)))
|| (g.x1 - g.x0 >= (view[2] - view[0]) * 0.5 && g.y1 - g.y0 < m * 0.3 && (g.x0 < L.left - m * 3 || g.x1 > L.right + m * 3)));
const drawn = await pageGraphics(page, frame);
const rules = drawn.filter(furniture);
const graphics = drawn.filter(g => !furniture(g));
// A column's left edge; a turned page, with no known column, reads its own body's.
const edgeOf = (col: number) => { const l = layoutOf(col).left; return isFinite(l) ? l : bodyExtent(lines, m).left; };
const flow = new Set(lines.filter(ln => isFlow(ln, layoutOf(ln.col), edgeOf(ln.col))));
const flowLines = [...flow];
const isEq = (ln: Line, cont = false) => !flow.has(ln) && isEquationLine(ln, layoutOf(ln.col), cont);
const chars = (ls: Line[]) => ls.reduce((s, ln) => s + ln.items.reduce((t, it) => t + it.str.trim().length, 0), 0);
const owned = (r: Box) => lines.filter(ln => ownedShare(ln, r, m) >= 0.6);
// A box (a frame, a shade) holding a listing or a prompt keeps its lines, as a code block. A
// boxed paragraph reads as prose and stays text; so does a ruled table.
const boxify = (b: Box) => {
const all = owned(b);
const own = all.filter(ln => !drop.has(ln));
if (own.length < 2 || own.slice(0, 4).some((_, i) => detectTable(own, i))) return;
if (!own.some(isMono) && !ragged(own, all)) return;
blocks.push({ top: Math.max(...own.map(ln => ln.y + ln.height)), bottom: Math.min(...own.map(ln => ln.y)), md: fenced(own), col: colOf(b) });
for (const ln of own) drop.add(ln);
};
// A lone frame or shade, text-sized, may be such a box.
const boxes = () => {
for (const g of graphics)
if (g.rect && !g.image && g.x1 - g.x0 >= L.textWidth * 0.3 && g.y1 - g.y0 >= m * 2 && (g.x1 - g.x0) * (g.y1 - g.y0) < pageArea * 0.6) boxify(g);
};
// A rule or two and no math: nothing to cut, and no render.
if (graphics.length < 3 && !graphics.some(g => g.image || !g.rect) && !lines.some(ln => isEq(ln))) { boxes(); return { figures, blocks, drop }; }
onProgress(`Cutting figures: page ${p} / ${n}`);
// The page renders turned to its frame. "print" intent: a display render paces itself with
// requestAnimationFrame, which never fires in a hidden tab, so a headless run would wait forever.
const vp = page.getViewport({ scale: RENDER_SCALE, rotation: frame.rot });
const ctx = canvas.getContext("2d")!;
canvas.width = Math.ceil(vp.width);
canvas.height = Math.ceil(vp.height);
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx, viewport: vp, canvas, background: "white", intent: "print" }).promise;
const R: Render = { canvas, vp, frame };
// The flow text leaves the ink item by item (a line clustered across the page can hold a
// paragraph's words and a figure's labels both), each with its descender; a big delimiter or
// a radical reads at its middle and hangs a full height either way.
const erased: Box[] = [];
for (const ln of flow) for (const it of ln.items) {
if (!it.str.trim()) continue;
const hangs = it.font.ext || /^√+$/.test(it.str.trim());
erased.push({ x0: it.x - 0.5, x1: it.x + it.width + 0.5, y0: it.y - it.height * (hangs ? 1 : 0.35), y1: it.y + it.height * 1.05 });
}
for (const g of rules) erased.push({ x0: g.x0 - 1, y0: g.y0 - 1, x1: g.x1 + 1, y1: g.y1 + 1 });
const ink = inkMask(R, erased);
const inside = (g: Box, r: Box) => { const cx = (g.x0 + g.x1) / 2, cy = (g.y0 + g.y1) / 2; return cx >= r.x0 - m && cx <= r.x1 + m && cy >= r.y0 - m && cy <= r.y1 + m; };
const classify = (r: Box): Region => {
const own = owned(r);
const gs = graphics.filter(g => inside(g, r));
// An image's box is often clipped short of what it shows, so the ink says how wide the figure
// is; the box only has to be more than an icon (a logo by a title).
const image = gs.some(g => g.image && (g.x1 - g.x0) * (g.y1 - g.y0) >= m * m * 9);
const drawing = gs.reduce((s, g) => s + (g.rect || g.image ? 0 : g.weight), 0);
const ownChars = chars(own), flowChars = chars(own.filter(ln => flow.has(ln)));
// Item dimensions are clamped: an outsized item (a vertical font reports its run as height)
// would otherwise claim a block of the region.
const textArea = own.reduce((s, ln) => s + ln.items.reduce((t, it) => t + Math.min(it.width, L.textWidth) * Math.min(it.height, 2 * m), 0), 0);
const density = textArea / ((r.x1 - r.x0) * (r.y1 - r.y0));
const captioned = lines.some(ln => ln.y <= r.y1 && ln.y >= r.y0 - m * 6 && FIG_CAPTION.test(captionText(ln, r, m)));
// An equation's lines run past its ink cluster (a "y =" before a tall fraction, the number at
// the margin): the equation lines set in the region's band count as its own.
const band = lines.filter(ln => !flow.has(ln) && !own.includes(ln) && isEq(ln, true) && inCol(ln, r) && inBand(ln, r));
const eqOwn = [...own.filter(ln => !flow.has(ln)), ...band];
const math = eqOwn.some(ln => isEq(ln)) && chars(eqOwn.filter(ln => isEq(ln, true))) * 2 >= ownChars + chars(band);
const big = r.y1 - r.y0 >= m * 3 && r.x1 - r.x0 >= L.textWidth * 0.15;
const note = `n${gs.length}${image ? " img" : ""} draw${drawing} d${density.toFixed(2)} flow${ownChars ? (flowChars / ownChars).toFixed(2) : "-"}${captioned ? " cap" : ""}${math ? " math" : ""}`;
const as = (kind: Kind, why: string, box: Box = r): Region => ({ ...box, kind, note, why, cap: captioned, took: 0 });
const figure = (why: string) => as(big ? "figure" : "stray", why);
// A page-sized image with body text laid over it is a background (a letterhead, a watermark).
if (image && (r.x1 - r.x0) * (r.y1 - r.y0) > pageArea * 0.75 && own.filter(ln => isBody(ln, L)).length >= 8) return as("text", "background");
// A figure caption claims what sits over it, however much text it carries (a diagram of
// labelled boxes and arrows, a box of prompts).
if (captioned) return figure("captioned");
// A line-height region in the band of a text line (a QED box, a bullet drawn as a path, a
// formula in a paragraph wrapped beside a figure) is the text's.
if (r.y1 - r.y0 <= m * 1.5 && flowLines.some(ln => inCol(ln, r) && inBand(ln, r))) return as("text", "on a line");
if (image) return figure("image");
// Heavy drawing is a figure whatever its text (a legend's samples, a diagram's icons). Light
// drawing yields to the text over it: among mostly flow text it is a box (a framed prompt, a
// shaded theorem, a listing's shade), and among math it is the equation's (a big operator
// drawn as paths).
if (drawing >= 400) return figure("drawing");
// A frame's own region (a box drawn around a theorem, a shaded definition) with prose in it
// is a text box whatever else it holds: the display inside is cut on its own, and a listing
// keeps its lines. Ten rules under braces would otherwise make it a diagram.
const framed = gs.some(g => g.rect && !g.image && (g.x1 - g.x0) * (g.y1 - g.y0) < pageArea * 0.6
&& Math.abs(g.x0 - r.x0) <= m / 2 && Math.abs(g.x1 - r.x1) <= m / 2 && Math.abs(g.y0 - r.y0) <= m / 2 && Math.abs(g.y1 - r.y1) <= m / 2);
if (framed && own.filter(ln => flow.has(ln) && isProse(ln)).length >= 2) return as("text", "framed");
if (ownChars && flowChars * 2 >= ownChars) return as("text", "text");
if (math) {
// Small glyphs alone (a stray limit, a subscript) are no equation, nor is math set within a
// prose line's band: inline math a floater merge missed.
if (eqOwn.every(ln => ln.items.every(it => !it.str.trim() || it.height < m * 0.75))) return as("stray", "small");
if (eqOwn.some(ln => ln.items.some(it => it.height >= m * 0.75) && flowLines.some(pl => !own.includes(pl) && pl.col === ln.col && isProse(pl) && Math.abs(pl.y - ln.y) < pl.height * 0.6))) return as("stray", "inline");
let box: Box = r;
for (const ln of eqOwn) {
const e = extent(ln);
box = union(box, { x0: e.x, x1: e.right, y0: ln.y - ln.height * 0.25, y1: ln.y + ln.height });
}
return as("equation", "equation", box);
}
if (drawing >= 60) return figure("drawing");
// Dense text over lines with little drawing is a text box or a table, whatever its lines. A
// text-dense grid is a table (a chart's aligned tick labels also detect as one, but sparsely).
if (density >= 0.5 && own.length >= 2) return as("text", "text box");
if (density >= 0.25 && own.slice(0, 4).some((_, i) => detectTable(own, i))) return as("text", "table");
// Ten drawn elements (boxes, rules, arrows) with labels among them is a diagram.
if (gs.length >= 10) return figure("diagram");
return as("stray", "stray");
};
const regions = inkRegions(R, ink, Math.max(2, Math.round(m / 2))).map(classify);
// A display set in a box: a shade's ink is one region, and a frame's takes what sits a cell from
// its stroke, so the display inside would stay in the text. Its lines (a run, a line apart, of
// lines that are neither flow text nor another region's, with a display's main row among them:
// centered in the box, wide, or tagged at its right edge) make an equation region of their own,
// the labels under its braces included, sized from its glyphs. A dense text box is a table more
// often than not, and a table's math row is no display.
for (const r of [...regions]) {
if (r.kind !== "text" || !["text", "framed", "background"].includes(r.why)) continue;
const loose = owned(r).filter(ln => !flow.has(ln) && !regions.some(o => o !== r && ownedShare(ln, o, m) >= 0.6)).sort((a, b) => b.y - a.y);
const display = (ln: Line) => {
const e = extent(ln);
return isEq(ln) && (Math.abs((e.x - r.x0) - (r.x1 - e.right)) <= m * 2 || e.right - e.x >= (r.x1 - r.x0) * 0.5
|| (TAG_END.test(joinLineText(ln, true).trim()) && e.right >= r.x1 - m * 1.5));
};
for (let i = 0; i < loose.length;) {
let j = i + 1;
while (j < loose.length && loose[j - 1].y - loose[j].y <= m * 1.5) j++;
const group = loose.slice(i, j);
i = j;
if (!group.some(display)) continue;
const box = group.flatMap(ln => ln.items.filter(it => it.str.trim())
.map(it => ({ x0: it.x, x1: it.x + it.width, y0: it.y - it.height * (it.font.ext ? 1 : 0.35), y1: it.y + it.height * 1.05 }))).reduce(union);
regions.push({ ...box, kind: "equation", note: r.note, why: "boxed", cap: false, took: 0 });
}
}
// A text line parts two regions. Joining equations: a body-sized line of words in text fonts,
// in their column, in their band and owned by neither (a case's label sits beside it, not
// between, and past the column's edge; a word tag between two rows is theirs). Joining figures:
// a flow line between them and across them, running half the column in one run, or a caption (a
// sentence in a diagram's box doesn't part its panels, nor does a row of panel titles, nor a
// panel's own title, "(b) ..."). A captioned figure is parted by a caption alone: the caption
// claims what sits over it. Joining strays: a flow line between them in their column.
const parted = (a: Box, b: Box, kind: Kind, cap = false) => {
const u = union(a, b);
if (kind === "equation") return lines.some(ln => inCol(ln, u) && inBand(ln, u) && ln.height >= m * 0.9
&& fontShare(ln, f => f.math) < 0.5 && ln.items.some(it => /[A-Za-z]{2,}/.test(it.str)) && !TAG_ALONE.test(joinLineText(ln, true).trim())
&& ownedShare(ln, a, m) < 0.6 && ownedShare(ln, b, m) < 0.6);
return flowLines.some(ln => {
if (ln.y <= Math.min(a.y1, b.y1) || ln.y >= Math.max(a.y0, b.y0)) return false;
if (kind !== "figure") return inCol(ln, u);
const e = extent(ln);
const text = joinLineText(ln, true).trim();
if (/^\(?[a-z]\)\s/i.test(text) || overlapX(ln, u) <= 0) return false;
return CAPTION.test(text) || (!cap && lineColumns(ln).length === 1 && e.right - e.x >= layoutOf(ln.col).textWidth * 0.5);
});
};
// What a region takes in: a figure, or a stray a caption claims, the figures, equations, and
// strays around it, and the text boxes too when captioned (a diagram drawn as tokens among its
// own sentences); an equation the equations and strays; and strays each other. A stray that
// took something is read again as one (a bar chart's bars, a scatter's points).
const figureLike = (r: Region) => r.kind === "figure" || (r.kind === "stray" && r.cap);
const takes = (a: Region, b: Region) =>
figureLike(a) ? b.kind !== "text" || (a.cap && b.why !== "background" && b.why !== "table") : a.kind === "equation" && (b.kind === "equation" || b.kind === "stray");
// Regions in different columns stay apart, whatever the gap: the gutter is narrow.
const apart = (a: Box, b: Box) => gutter !== null && colOf(a) >= 0 && colOf(b) >= 0 && colOf(a) !== colOf(b);
// A region with flow text a line over or under it is set in a paragraph (a formula in the text
// wrapped beside a figure): nothing reaches sideways for it.
const inText = (r: Box) => flowLines.some(ln => inCol(ln, r) && overlapX(ln, r) > 0.3
&& (Math.abs(ln.y - r.y1) <= m * 1.2 || Math.abs(ln.y - r.y0) <= m * 1.2));
for (let merged = true; merged;) {
merged = false;
for (let i = 0; i < regions.length && !merged; i++) {
for (let j = i + 1; j < regions.length; j++) {
const a = regions[i], b = regions[j];
if (apart(a, b)) continue;
const dy = Math.max(a.y0 - b.y1, b.y0 - a.y1), dx = Math.max(a.x0 - b.x1, b.x0 - a.x1);
const big = takes(a, b) ? a : takes(b, a) ? b : null;
if (big) {
const other = big === a ? b : a;
const kind: Kind = figureLike(big) ? "figure" : big.kind;
const reachY = kind === "figure" ? m * 3 : m * 2.5;
// A captioned figure reaches further sideways: a diagram's tokens sit apart.
const reachX = kind === "figure" ? (big.cap ? m * 4 : figureLike(other) ? m * 2 : m) : m;
if (dy > reachY || dx > reachX || (dx > 0 && !figureLike(other) && inText(other)) || parted(a, b, kind, big.cap)) continue;
regions[i] = big.kind === "stray" ? classify(union(a, b))
: { ...union(a, b), kind: big.kind, note: big.note, why: big.why, cap: a.cap || b.cap, took: big.took + other.took + 1 };
} else if (a.kind === "stray" && b.kind === "stray") {
if (dy > m * 3 || dx > m || parted(a, b, "stray")) continue;
regions[i] = classify(union(a, b));
} else continue;
regions.splice(j, 1);
merged = true;
break;
}
}
}
// Each region's fate, for the log: the cuts are the pipeline's opaque part.
const fates: string[] = [];
// A tag set between a display's rows ((CR) beside two rows of integrals) is in neither row's
// band until the rows merge: the merged equation takes it in.
for (const r of regions) {
if (r.kind !== "equation") continue;
for (const ln of lines) {
if (flow.has(ln) || !inCol(ln, r) || !inBand(ln, r) || !TAG_ALONE.test(joinLineText(ln, true).trim())) continue;
const e = extent(ln);
Object.assign(r, union(r, { x0: e.x, x1: e.right, y0: ln.y - ln.height * 0.25, y1: ln.y + ln.height }));
}
}
const eqs: string[] = [];
const strays: string[] = [];
for (const r of regions.sort((a, b) => b.y1 - a.y1)) {
if (r.kind === "stray") { strays.push(`${at(r)} ${r.note} (${r.why})`); continue; }
fates.push(`${at(r)} ${r.note}: ${r.kind} (${r.why}${r.took ? `, +${r.took}` : ""})`);
if (r.kind === "text") continue;
// The region's lines go with it; a caption stays, and so does flow text in an equation's box
// (a short line of prose beside a tall radical's top): an equation has none of its own.
const eq = r.kind === "equation";
const own = owned(r).filter(ln => !isCaption(ln, r, m) && !(eq && flow.has(ln)));
for (const ln of own) drop.add(ln);
// The crop: the ink with a little room, short of the flow text over and under it (a
// paragraph's descenders reach nearly to a display's superscripts); what of that text still
// reaches in (a caption's ascenders past an image's lowest pixels, that line of prose by the
// radical) is painted out.
const rect: Box = { x0: r.x0 - 2, y0: r.y0 - 2, x1: r.x1 + 2, y1: r.y1 + 2 };
const holes: Box[] = [];
for (const ln of flow) for (const it of ln.items) {
if (!it.str.trim() || it.x >= rect.x1 || it.x + it.width <= rect.x0) continue;
if (it.y >= r.y1) rect.y1 = Math.min(rect.y1, Math.max(r.y1, it.y - it.height * 0.25));
else if (it.y < r.y0) rect.y0 = Math.max(rect.y0, Math.min(r.y0, it.y + it.height * 0.8));
else if (!eq) continue;
// With a point around: an italic's overhang and a glyph's antialiasing run past its box.
holes.push({ x0: it.x - 1.5, x1: it.x + it.width + 1.5, y0: it.y - it.height * 0.3, y1: it.y + it.height * 1.05 });
}
const toPage = (b: Box) => boxUnder(frame.from, b.x0, b.y0, b.x1, b.y1);
const png = await cropFigure(canvas, vp, toPage(rect), holes.map(toPage));
if (!png) continue;
const k = figures.filter(f => isEquationFile(f) === eq).length + 1;
let alt: string;
let eqn: Figure["eq"];
if (eq) {
// The equation's numbers: each line ending in "(n)" at the right edge (a superscript row can
// end in "(0)", short of it); consecutive equations share a crop.
const right = Math.max(...own.map(ln => extent(ln).right));
const tags = own.filter(ln => extent(ln).right >= right - m)
.map(ln => joinLineText(ln, true).match(/\((\d+[a-z]?)\)\s*$/)?.[1]).filter((t): t is string => !!t);
alt = tags.length ? `Equation${tags.length > 1 ? "s" : ""} (${tags.join("), (")})` : "Equation";
eqn = { tags, text: own.map(ln => joinLineText(ln, true).trim()).join(" ") };
eqs.push(JSON.stringify(own.map(ln => joinLineText(ln).trim()).join(" / ")));
} else alt = captionFor(lines, L, rect, "");
figures.push({ top: rect.y1, bottom: rect.y0, png, file: `figures/page-${pad(p)}-${eq ? "eq" : "fig"}-${k}.png`, alt, col: colOf(rect), eq: eqn });
}
// Panels side by side share the caption set under one of them; a figure with none is named
// for its page.
const figs = figures.filter(f => !isEquationFile(f));
const captioned = figs.filter(f => f.alt);
figs.forEach((f, i) => {
if (f.alt) return;
const beside = captioned.find(o => Math.min(f.top, o.top) - Math.max(f.bottom, o.bottom) >= Math.min(f.top - f.bottom, o.top - o.bottom) * 0.5);
f.alt = beside?.alt || `Figure ${i + 1} on page ${p}`;
});
boxes();
if (strays.length) fates.push(`stray: ${strays.join(", ")}`);
if (rules.length) fates.push(`furniture: ${rules.map(at).join(", ")}`);
tb.log(`[fig] page ${p}: ${fates.join("; ")}`);
if (eqs.length) tb.log(`[eq] page ${p}: ${eqs.join(", ")}`);
return { figures, blocks, drop };
}
function pageToMarkdown(lines: Line[], L: Layout, layoutOf: (col: number) => Layout, blocks: Block[], drop: Set<Line>): string {
// Text inside a figure, a box, or an equation travels with its block, not the text.
lines = lines.filter(ln => !drop.has(ln));
// A page number: a lone number as the page's first or last line.
const texty = lines.filter(ln => ln.items.some(it => it.str.trim()));
const pageNos = [texty[0], texty[texty.length - 1]].filter(ln => ln && isPageNumber(ln));
lines = lines.filter(ln => !pageNos.includes(ln));
// Reading order: strips down the page, cut where a line or a block spans the columns; a strip's
// columns read left then right, each as a page of its own. A strip with a column empty is no
// two-column region (a spanning paragraph's short last line) and reads on with its neighbours.
type Unit = { y: number; col: number; line?: Line; block?: Block };
type Strip = { spanning: boolean; cols: Unit[][] };
const units: Unit[] = [
...lines.map(ln => ({ y: ln.y, col: ln.col, line: ln })),
...blocks.map(b => ({ y: b.bottom, col: b.col, block: b })),
].sort((a, b) => b.y - a.y);
const strips: Strip[] = [];
for (const u of units) {
const spanning = u.col < 0;
let strip = strips[strips.length - 1];
if (!strip || strip.spanning !== spanning) strips.push(strip = { spanning, cols: [[], []] });
strip.cols[spanning ? 0 : u.col].push(u);
}
const merged: Strip[] = [];
for (const s of strips) {
const spanning = s.spanning || s.cols.some(c => !c.length);
const last = merged[merged.length - 1];
if (spanning && last?.spanning) last.cols[0].push(...s.cols[0], ...s.cols[1]);
else merged.push(spanning ? { spanning, cols: [[...s.cols[0], ...s.cols[1]], []] } : s);
}
let out = "";
for (const s of merged)
s.cols.forEach((us, c) => {
if (us.length) out += columnToMarkdown(us.flatMap(u => u.line ? [u.line] : []), layoutOf(s.spanning ? -1 : c), us.flatMap(u => u.block ? [u.block] : []));
});
return out;
}
// A column's lines (top first) and the blocks in it, as markdown.
function columnToMarkdown(lines: Line[], L: Layout, blocks: Block[]): string {
const medH = L.medH;
const headed = lines.some(ln => CONTENTS_HEAD.test(joinLineText(ln, true).trim()));
const pending = [...blocks].sort((a, b) => b.top - a.top);
const emitBlocksAbove = (y: number) => {
let s = "";
while (pending.length && pending[0].bottom > y) s += pending.shift()!.md;
return s;
};
let out = "";
let i = 0;
while (i < lines.length) {
if (lines[i].items.every(it => !it.str.trim())) { i++; continue; }
out += emitBlocksAbove(lines[i].y);
if (startsListing(lines, i)) {
const j = monoRunEnd(lines, i);
out += "\n" + fenced(lines.slice(i, j));
i = j;
continue;
}
const toc = contentsRun(lines, i, medH, headed);
if (toc) {
out += "\n" + toc.md + "\n";
i = toc.end;
continue;
}
const table = detectTable(lines, i);
if (table) {
out += "\n" + renderTable(table.rows) + "\n";
i = table.end;
continue;
}
const line = lines[i];
const text = joinLineText(line).trim();
if (!text) { i++; continue; }
const h = classifyHeading(line, L);
if (h) {
// A wrapped heading continues on same-level lines a line apart.
let ht = unstyled(text);
let j = i + 1;
while (j < lines.length && classifyHeading(lines[j], L) === h && lines[j - 1].y - lines[j].y <= lines[j - 1].height * 1.6) {
const t2 = joinLineText(lines[j]).trim();
if (!t2) break;
ht = joinWrapped(ht, unstyled(t2));
j++;
}
out += `\n${hashes(h)} ${ht}\n\n`;
i = j;
continue;
}
const ch = capsHeading(text, line, lines[i - 1], L);
if (ch) {
out += `\n${hashes(ch)} ${unstyled(text)}\n\n`;
i++;
continue;
}
const lst = getListMarker(text);
if (lst) {
// An item's wrapped lines start past its marker, a line apart.
let item = lst.rest;
let j = i + 1;
while (j < lines.length) {
const ln = lines[j];
const t2 = joinLineText(ln).trim();
if (!t2 || getListMarker(t2) || classifyHeading(ln, L) || startsListing(lines, j) || detectTable(lines, j)) break;
if (lines[j - 1].y - ln.y > lines[j - 1].height * 1.6 || Math.abs(ln.height - line.height) > 0.5) break;
if (extent(ln).x <= extent(line).x + medH * 0.3) break;
item = joinWrapped(item, t2);
j++;
}
out += lst.kind === "ul" ? `- ${item}\n` : `1. ${item}\n`;
i = j;
const nxt = lines[i];
if (!nxt || !getListMarker(joinLineText(nxt).trim())) out += "\n";
continue;
}
// Paragraph: gather following body lines.
const run = [line];
const texts = [text];
const beside = (ln: Line) => blocks.some(b => ln.y <= b.top + medH * 1.5 && ln.y >= b.bottom - medH * 1.5);
let j = i + 1;
while (j < lines.length) {
const ln = lines[j];
const t2 = joinLineText(ln).trim();
if (!t2) break;
if (classifyHeading(ln, L)) break;
if (startsListing(lines, j)) break;
if (getListMarker(t2)) break;
if (capsHeading(t2, ln, lines[j - 1], L)) break;
const gap = lines[j - 1].y - ln.y;
if (gap > lines[j - 1].height * 1.6) break;
// A font-size change ends the paragraph: keeps footnotes off the body above them.
if (Math.abs(ln.height - lines[j - 1].height) > 0.5) break;
if (detectTable(lines, j)) break;
// A first-line indent after a full line starts a paragraph, where the style leaves no space.
const prev = extent(lines[j - 1]);
if (!beside(ln) && extent(ln).x > prev.x + medH && prev.right >= Math.max(...run.map(r => extent(r).right)) - medH * 2) break;
run.push(ln);
texts.push(t2);
j++;
}
// A line that stops short of the block's right edge with text under it was broken on purpose
// (an author block, a listing), and the break stays, as markdown's trailing backslash: justified
// prose only stops short on its last line, or beside a figure. A centered block's lines stop
// short by half as much each side, and a narrower justified block (an abstract) is symmetric
// too but keeps one left edge, so a centered line breaks when the next starts elsewhere.
const blockRight = Math.max(...run.map(ln => extent(ln).right));
const keeps = (k: number) => (!beside(run[k]) && extent(run[k]).right < blockRight - L.textWidth * 0.2)
|| (centered(run[k], L) && Math.abs(extent(run[k]).x - extent(run[k + 1]).x) > medH * 0.8);
let paraText = texts[0];
for (let k = 1; k < texts.length; k++)
paraText = keeps(k - 1) ? `${paraText}\\\n${texts[k]}` : joinWrapped(paraText, texts[k]);
// A line opening with "#" or ">" (a listing's comment, a quote) would render as markup.
out += paraText.replace(/^([#>])/gm, "\\$1") + "\n\n";
i = j;
}
out += emitBlocksAbove(-Infinity);
return out;
}
const padOf = (total: number) => (i: number) =>
String(i).padStart(Math.max(3, String(total).length), "0");
interface Conversion {
srcName: string;
// Where the PDF came from: a URL, a path, or a dropped file's name (all a browser tells).
source: string;
stem: string;
pages: number;
figures: FigureFile[];
md: string;
totalChars: number;
isScan: boolean;
// The text column's width in points: what a figure's size on the page is read against.
textWidth: number;
// The equations that carry their LaTeX, and the arXiv id (with its version) the paper reads
// as, or "".
latex: number;
arxiv: string;
}
// Figure files by kind: a display equation is cut like a figure and named for it.
const isEquationFile = (f: FigureFile) => f.file.includes("-eq-");
function buildDocument(src: Src, pageMds: string[], figures: FigureFile[], isScan: boolean, textWidth: number, latex: number, arxiv: string): string {
const n = pageMds.length;
const nEqs = figures.filter(isEquationFile).length;
const nFigs = figures.length - nEqs;
const cut = [nFigs && `${nFigs} ${nFigs === 1 ? "figure" : "figures"}`, nEqs && `${nEqs} display ${nEqs === 1 ? "equation" : "equations"}`].filter(Boolean).join(" and ");
let out = `# Markdown of ${src.name}\n\nSource: ${src.source}\n\n`;
out += `${n} ${n === 1 ? "page" : "pages"}. The text below is in reading order with \`--- Page N ---\` markers (a two-column page reads column by column, so a paragraph can break where a column ends); headings, lists, and tables are inferred from layout. `;
out += isScan
? `This PDF has no text layer (a scan), so each page is a figure: `
: `${cut || "No figures"} ${figures.length === 1 ? "was" : "were"} cut out of the pages as PNG files under figures/ (${RENDER_SCALE * 72} DPI; the text column is ${Math.round(textWidth)} pt wide), `;
out += `referenced where ${figures.length === 1 ? "it appears" : "they appear"} (\`\`). `;
out += latex
? `${latex} of the equations carry their LaTeX from arXiv:${arxiv} in $$ blocks under the image. Open an image to read a chart, a diagram, or an equation without one.\n\n`
: `Open one to read a chart, a diagram, or an equation.\n\n`;
for (let i = 0; i < n; i++) {
out += `--- Page ${i + 1} ---\n\n`;
out += (pageMds[i] || `(nothing on this page)`) + "\n\n";
}
return out.replace(/\n{3,}/g, "\n\n").trim() + "\n";
}
const safeStem = (name: string) =>
name.replace(/\.pdf$/i, "").replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "document";
// An arXiv paper's id, with its version when given ("2510.21150v1", "hep-th/9711200"): off the URL
// the PDF came from (abs, pdf, or html, with or without .pdf, export.arxiv.org too), or off the
// stamp arXiv sets up page 1's margin ("arXiv:2510.21150v1 [cs.CL] 24 Oct 2025").
function arxivIdOf(s: string): string | null {
const m = s.match(/(?:arxiv\.org\/(?:abs|pdf|html)\/|arXiv:)(\d{4}\.\d{4,5}|[a-z-]+(?:\.[A-Z]{2})?\/\d{7})(v\d+)?/);
return m ? m[1] + (m[2] ?? "") : null;
}
// A line of an arXiv paper's display math, off arxiv.org's HTML rendering: its number, if any,
// its TeX by cell (LaTeXML keeps each <math>'s in its alttext, and cuts an aligned row at its &
// into one cell each, prefixed \displaystyle, empty where the source left it), and the group it
// belongs to (an align's rows share one; a single equation is its own). An align numbers its
// rows; an equation set as a split or an aligned numbers its group as a whole, and that number is
// the group's.
interface TexGroup { tag: string | null }
interface TexRow { tag: string | null; cells: string[]; group: TexGroup }
// The paper's display math in document order: every equation row, whether set as a table row or
// as a span (an equation in a theorem's statement). Null when the paper has no HTML: that 404
// carries no CORS header, so the fetch rejects rather than reporting it.
async function arxivLatex(id: string): Promise<TexRow[] | null> {
let html: string;
try {
const r = await fetch(`https://arxiv.org/html/${id}`);
if (!r.ok) return null;
html = await r.text();
} catch { return null; }
const rows: TexRow[] = [];
const groups = new Map<Element, TexGroup>();
const numberOf = (el: Element | null) => el?.textContent?.match(/\(([^()\s]+)\)/)?.[1] ?? null;
// Vertical space is the page's business (a "\vskip-5.0pt" closing an equation), and a command
// KaTeX lacks; so is a cross-reference, which the rendering resolves to its number, one
// .ltx_ref_tag per reference in order (\eqref sets the parentheses, \ref does not).
const texOf = (el: Element | null) => {
if (!el) return "";
const nums = Array.from(el.querySelectorAll(".ltx_ref_tag")).map(r => r.textContent!.trim());
let i = 0;
return el.getAttribute("alttext")!.replace(/^\\displaystyle/, "")
.replace(/\\(?:vskip\s*-?[\d.]+\s*[a-z]{2}|vspace\*?\{[^{}]*\}|(?:small|med|big)skip\b)/g, "")
.replace(/\\(eq)?ref\{[^{}]*\}/g, (_, eq) => { const n = nums[i++] ?? "??"; return eq ? `\\text{(${n})}` : `\\text{${n}}`; })
.replace(/\s+/g, " ").trim();
};
for (const row of Array.from(new DOMParser().parseFromString(html, "text/html").querySelectorAll(".ltx_equation.ltx_eqn_row"))) {
// An aligned row's cells are its td.ltx_td; a lone equation has none, and one math.
const tds = Array.from(row.querySelectorAll("td.ltx_td"));
const cells = tds.length ? tds.map(td => texOf(td.querySelector("math[alttext]"))) : Array.from(row.querySelectorAll("math[alttext]")).map(texOf);
if (!cells.some(Boolean)) continue;
const g = row.closest(".ltx_equationgroup") ?? row;
if (!groups.has(g)) groups.set(g, { tag: numberOf(g.querySelector(".ltx_tag_equationgroup")) });
rows.push({ tag: numberOf(row.querySelector(".ltx_tag_equation")), cells, group: groups.get(g)! });
}
return rows;
}
// Some math as a bag of its letters, digit runs, and Greek: a crop's text layer and the LaTeX of
// the same equation hold the same ones, whatever order a fraction's rows read in. Greek variants
// fold together (which of ϕ and φ a font's glyph maps to is the font's business).
const GREEK = new Map(Object.entries({ alpha: "α", beta: "β", gamma: "γ", delta: "δ", epsilon: "ε", varepsilon: "ε", zeta: "ζ", eta: "η", theta: "θ", vartheta: "θ", iota: "ι", kappa: "κ", lambda: "λ", mu: "μ", nu: "ν", xi: "ξ", pi: "π", varpi: "π", rho: "ρ", varrho: "ρ", sigma: "σ", varsigma: "σ", tau: "τ", upsilon: "υ", phi: "φ", varphi: "φ", chi: "χ", psi: "ψ", omega: "ω", Gamma: "Γ", Delta: "Δ", Theta: "Θ", Lambda: "Λ", Xi: "Ξ", Pi: "Π", Sigma: "Σ", Upsilon: "Υ", Phi: "Φ", Psi: "Ψ", Omega: "Ω" }));
const GREEK_FOLD: Record<string, string> = { "ϕ": "φ", "ϵ": "ε", "ϑ": "θ", "ϖ": "π", "ϱ": "ρ", "ς": "σ" };
const OPERATORS = new Set(["ln", "log", "lg", "exp", "min", "max", "sin", "cos", "tan", "sec", "csc", "cot", "arcsin", "arccos", "arctan", "sinh", "cosh", "tanh", "lim", "sup", "inf", "det", "dim", "arg", "Pr", "gcd", "argmax", "argmin", "deg", "ker", "mod", "bmod", "pmod"]);
function mathBag(s: string): Map<string, number> {
const bag = new Map<string, number>();
for (const t of s.replace(/[ϕϵϑϖϱς]/g, c => GREEK_FOLD[c]).match(/[A-Za-z]|\d+|[α-ωΑ-Ω]/g) || []) bag.set(t, (bag.get(t) || 0) + 1);
return bag;
}
// The LaTeX as the text layer shows it: Greek and operator names as their glyphs, other commands
// gone, and gone with their arguments the ones whose arguments never print (an environment's
// name, a label).
const texText = (tex: string) => tex
.replace(/\\(?:begin|end|label|ref|eqref|tag|hspace|vspace|phantom|hphantom|vphantom)\*?\{[^{}]*\}(?:\{[^{}]*\})?/g, " ")
.replace(/\\([A-Za-z]+)/g, (_, c) => GREEK.get(c) ?? (OPERATORS.has(c) ? ` ${c} ` : " "));
// Dice's coefficient of two bags.
function dice(a: Map<string, number>, b: Map<string, number>): number {
let both = 0, na = 0, nb = 0;
for (const [t, k] of a) { na += k; both += Math.min(k, b.get(t) || 0); }
for (const k of b.values()) nb += k;
return na + nb ? (2 * both) / (na + nb) : 0;
}
// A crop's text layer and its LaTeX agree from here up. Below it the LaTeX is left out: a number
// LaTeXML counted differently from the PDF would pass another equation off as this one.
const LATEX_MATCH = 0.6;
// A crop's LaTeX ($$ blocks, or none) and the log's account of it: the score, the count when
// lines were added, and on a rejection the number whose LaTeX fits best instead. at: the rows
// matched.
interface Latex { md: string; note: string; at?: [number, number] }
const tex = (rs: TexRow[]) => rs.map(r => r.cells.join(" ")).join(" ");
// A row's number: its own, else its group's.
const numOf = (r: TexRow) => r.tag ?? r.group.tag;
// How well a crop's text (its numbers out) agrees with some rows' LaTeX.
const cropBag = (f: Figure) => mathBag(f.eq!.text.replace(/\(\d+[a-z]?\)/g, ""));
const agree = (bag: Map<string, number>, rs: TexRow[]) => dice(bag, mathBag(texText(tex(rs))));
// Rows a to b as $$ blocks. A display numbering its rows is a block per row, each numbered one
// carrying its \tag; one numbered as a whole, or not at all, is one block of its rows aligned at
// their cells' seams, the \tag after.
function texBlocks(rows: TexRow[], a: number, b: number): string {
const byRow = new Set(rows.filter(r => r.tag).map(r => r.group));
let md = "";
for (let i = a; i <= b;) {
const g = rows[i].group;
let j = i;
if (!byRow.has(g)) while (j < b && rows[j + 1].group === g) j++;
const rs = rows.slice(i, j + 1);
const body = !byRow.has(g) && (rs.length > 1 || rs[0].cells.length > 1)
? `\\begin{aligned}${rs.map(r => r.cells.join(" & ")).join(" \\\\ ")}\\end{aligned}` : tex(rs);
const tag = g.tag ?? rows[i].tag;
md += `$$\n${body}${tag ? ` \\tag{${tag}}` : ""}\n$$\n\n`;
i = j + 1;
}
return md;
}
// A numbered crop's LaTeX, when its text agrees with it: its numbers' rows, and around them
// whichever untagged rows of their groups the text agrees with best (a derivation numbers its last
// line only, and the crop holds all of it). Nothing when the HTML lacks the number.
function latexBlocks(f: Figure, rows: TexRow[]): Latex {
const tags = f.eq!.tags.filter(t => rows.some(r => numOf(r) === t));
if (!tags.length) return { md: "", note: "" };
const idx = rows.flatMap((r, i) => { const n = numOf(r); return n !== null && tags.includes(n) ? [i] : []; });
const bag = cropBag(f);
const score = (a: number, b: number) => agree(bag, rows.slice(a, b + 1));
const lo = Math.min(...idx), hi = Math.max(...idx);
const untaggedIn = (i: number, g: TexGroup) => i >= 0 && i < rows.length && !rows[i].tag && rows[i].group === g;
let best = { a: lo, b: hi, s: score(lo, hi) };
for (let a = lo; a === lo || untaggedIn(a, rows[lo].group); a--)
for (let b = hi; b === hi || untaggedIn(b, rows[hi].group); b++) {
const s = score(a, b);
if (s > best.s) best = { a, b, s };
}
let note = `(${tags.join("), (")}) ${best.s.toFixed(2)}`;
if (best.b - best.a > hi - lo) note += ` over ${best.b - best.a + 1} lines`;
if (best.s < LATEX_MATCH) {
const other = rows.filter(r => numOf(r) && !tags.includes(numOf(r)!)).map(r => [numOf(r), agree(bag, [r])] as const).sort((x, y) => y[1] - x[1])[0];
return { md: "", note: note + ` rejected${other ? `, best (${other[0]}) ${other[1].toFixed(2)}` : ""}` };
}
return { md: texBlocks(rows, best.a, best.b), note, at: [best.a, best.b] };
}
// The pairing of two sequences, in order, that agrees best over all: a pair scoring under
// LATEX_MATCH is no pair, and an item can go unpaired. Each pair with its score.
function pairUp(n: number, m: number, w: (i: number, j: number) => number): [number, number, number][] {
const W = Array.from({ length: n }, (_, i) => Array.from({ length: m }, (_, j) => w(i, j)));
const best = Array.from({ length: n + 1 }, () => new Float64Array(m + 1));
for (let i = 1; i <= n; i++) for (let j = 1; j <= m; j++) {
const s = W[i - 1][j - 1];
best[i][j] = Math.max(best[i - 1][j], best[i][j - 1], s >= LATEX_MATCH ? best[i - 1][j - 1] + s : 0);
}
const pairs: [number, number, number][] = [];
for (let i = n, j = m; i > 0 && j > 0;) {
if (best[i][j] === best[i - 1][j]) i--;
else if (best[i][j] === best[i][j - 1]) j--;
else { pairs.push([i - 1, j - 1, W[i - 1][j - 1]]); i--; j--; }
}
return pairs.reverse();
}
// The LaTeX of the document's equation crops, by crop. A numbered crop's is its numbers' rows,
// when the HTML knows the number (one it doesn't is text). An unnumbered crop's is found by its
// place: between two numbered crops, the unnumbered crops and the HTML's unnumbered displays run
// in the same order, so they pair off in order by how well each crop's text agrees with a
// display's LaTeX, a display the pipeline kept as text or a crop the HTML has no display for going
// unpaired. A display tagged with a word ((Curve), (IC)), whose crop carries no number, pairs off
// with the unnumbered ones and keeps its \tag. The crops read in page order, a two-column
// page's left column before its right.
function latexFor(pages: Figure[][], rows: TexRow[]): Map<Figure, Latex> {
const out = new Map<Figure, Latex>();
const crops = pages.flatMap(figs => figs.filter(f => f.eq).sort((a, b) => Math.max(0, a.col) - Math.max(0, b.col) || b.top - a.top));
const numbered = (f: Figure) => f.eq!.tags.some(t => rows.some(r => numOf(r) === t));
// The displays as row ranges, and each row's.
const units: [number, number][] = [];
const unitOf: number[] = [];
rows.forEach((r, i) => {
if (i && rows[i - 1].group === r.group) units[units.length - 1][1] = i; else units.push([i, i]);
unitOf.push(units.length - 1);
});
const rowsOf = (u: number) => rows.slice(units[u][0], units[u][1] + 1);
// A number as cutPage reads one off a crop's line end.
const numeric = (t: string | null) => t !== null && /^\d+[a-z]?$/.test(t);
const unnumbered = (u: number) => rowsOf(u).every(r => !numeric(r.tag) && !numeric(r.group.tag));
const bags = units.map((_, u) => mathBag(texText(tex(rowsOf(u)))));
// The numbered crops first: those whose LaTeX was found anchor the rest, in order.
const anchors = [{ c: -1, lo: -1, hi: -1 }];
crops.forEach((f, c) => {
if (!numbered(f)) return;
const l = latexBlocks(f, rows);
out.set(f, l);
if (!l.at) return;
const lo = unitOf[l.at[0]], hi = unitOf[l.at[1]];
if (lo >= anchors[anchors.length - 1].hi) anchors.push({ c, lo, hi });
});
anchors.push({ c: crops.length, lo: units.length, hi: units.length });
for (let k = 0; k + 1 < anchors.length; k++) {
const cs: number[] = [], us: number[] = [];
for (let c = anchors[k].c + 1; c < anchors[k + 1].c; c++) if (!numbered(crops[c])) cs.push(c);
for (let u = anchors[k].hi + 1; u < anchors[k + 1].lo; u++) if (unnumbered(u)) us.push(u);
const cropBags = cs.map(c => cropBag(crops[c]));
const pairs = pairUp(cs.length, us.length, (i, j) => dice(cropBags[i], bags[us[j]]));
cs.forEach((c, i) => {
const p = pairs.find(([pi]) => pi === i);
if (p) { out.set(crops[c], { md: texBlocks(rows, units[us[p[1]]][0], units[us[p[1]]][1]), note: `(unnumbered) ${p[2].toFixed(2)}`, at: units[us[p[1]]] }); return; }
const near = us.length ? `, best ${Math.max(...us.map(u => dice(cropBags[i], bags[u]))).toFixed(2)}` : "";
out.set(crops[c], { md: "", note: `(unnumbered) none${near}` });
});
}
return out;
}
// The equations of a document that carry their LaTeX: the image references with a $$ block under.
const latexCount = (md: string) => (md.match(/^!\[Equation[^\n]*\n\n\$\$$/gm) || []).length;
async function processBytes(
src: Src,
onProgress: (s: string) => void
): Promise<Conversion> {
const pdf = await pdfjs.getDocument({
data: src.bytes.slice(), // the worker detaches the buffer it is given; keep ours
cMapUrl: tb.proxy(`https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`),
cMapPacked: true,
standardFontDataUrl: tb.proxy(`https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`),
}).promise;
try {
const n = pdf.numPages;
const pad = padOf(n);
const pages: Page[] = [];
const fontNames = new Set<string>();
let stamp: string | null = null;
for (let p = 1; p <= n; p++) {
onProgress(`Reading text: page ${p} / ${n}`);
const page = await pdf.getPage(p);
// The operator list first: fetching it is what loads the page's fonts, whose names carry
// weight, slant, and pitch. The figure pass reads the same cached list.
await page.getOperatorList();
const tc = await page.getTextContent();
const frame = pageFrame(tc.items, page.view);
const { items, margin } = extractItems(tc, pageFonts(page, tc, fontNames), frame);
if (!stamp) stamp = margin.map(arxivIdOf).find(Boolean) ?? null;
pages.push({ lines: groupIntoLines(items), frame });
}
tb.log(`[fonts] ${[...fontNames].join(", ")}`);
// The stamp names the version these bytes are; a URL may not. The fetch is the page's own,
// from the user's address: the interactive use arXiv's access policy puts first, with no
// typebulb server in between (its terms forbid one).
const arxiv = stamp ?? arxivIdOf(src.source) ?? "";
let tex: TexRow[] | null = null;
if (arxiv) {
onProgress("Fetching LaTeX from arXiv...");
tex = await arxivLatex(arxiv);
tb.log(tex ? `[arxiv] ${arxiv}: ${tex.length} lines of display math in its HTML, ${new Set(tex.map(r => r.tag ?? r.group.tag).filter(Boolean)).size} numbers` : `[arxiv] no HTML for ${arxiv}`);
}
const turned = pages.map((pg, i) => pg.frame.rot ? `${i + 1} (${pg.frame.rot}°)` : "").filter(Boolean);
if (turned.length) tb.log(`[frame] turned pages: ${turned.join(", ")}`);
const layout = measureLayout(pages);
// A page's body size is the document's where the page sets a few lines in it or has little
// text at all, else its own: a page of transcripts set small, or a figure page whose caption
// is its largest text. The column extents stay the document's.
const medHs = pages.map(pg => {
const chars = (f: (it: TextItem) => boolean) =>
pg.lines.flatMap(ln => ln.items).filter(f).reduce((s, it) => s + it.str.trim().length, 0);
const inDoc = chars(it => Math.abs(it.height - layout.medH) <= layout.medH * 0.01);
return (inDoc < 200 && chars(() => true) >= 300 && bodyHeight(pg.lines)) || layout.medH;
});
const resized = medHs.flatMap((h, i) => h !== layout.medH ? [`${i + 1} (${h.toFixed(1)})`] : []);
tb.log(`[body] ${layout.medH.toFixed(1)}pt${resized.length ? `; pages measuring otherwise: ${resized.join(", ")}` : ""}`);
// Each page's gutter, and the document's where three or more of its pages (in its frame) agree
// within a line height: it then applies to a page too sparse or too tabular to show its own.
const gutters = pages.map(pg => findGutter(pg.lines, layout.medH));
const found = pages.flatMap((pg, i) => pg.frame.rot === layout.rot && gutters[i] !== null ? [gutters[i]!] : []).sort((a, b) => a - b);
const mid = found[found.length >> 1];
const docGutter = found.filter(g => Math.abs(g - mid) <= layout.medH).length >= 3 ? mid : null;
const twoCol = gutters.flatMap((g, i) => g !== null ? [i + 1] : []);
if (twoCol.length) tb.log(`[columns] two-column pages (${twoCol.length} of ${n}): ${twoCol.join(", ")}${docGutter !== null ? `; the document's gutter at x=${docGutter.toFixed(0)}` : ""}`);
// The cuts: a page is rendered only when it draws something or sets math.
const canvas = document.createElement("canvas");
const cut: { cuts: Cuts; lines: Line[]; L: Layout; layoutOf: (col: number) => Layout }[] = [];
for (let p = 1; p <= n; p++) {
const page = await pdf.getPage(p);
const { frame } = pages[p - 1];
const medH = medHs[p - 1];
// A page turned the other way (a wide table) has no known column: its edges are the page's.
const L: Layout = frame.rot === layout.rot ? { ...layout, medH } : { ...layout, medH, left: -Infinity, right: Infinity };
const gutter = frame.rot === layout.rot && docGutter !== null ? docGutter : gutters[p - 1];
const { lines, layoutOf } = columnize(pages[p - 1].lines, gutter, L);
cut.push({ cuts: await cutPage({ page, p, n, pad, lines, frame, L, layoutOf, gutter }, canvas, onProgress), lines, L, layoutOf });
}
const figures = cut.flatMap(c => c.cuts.figures);
// The LaTeX waits for every page: an unnumbered crop is placed among the numbered ones.
const latexOf = tex ? latexFor(cut.map(c => c.cuts.figures), tex) : new Map<Figure, Latex>();
const pageMds = cut.map(({ cuts, lines, L, layoutOf }, i) => {
const notes: string[] = [];
const figBlocks = cuts.figures.map(f => {
const l = latexOf.get(f);
if (l?.note) notes.push(l.note);
return { top: f.top, bottom: f.bottom, md: `\n\n${l?.md ?? ""}`, col: f.col };
});
if (notes.length) tb.log(`[latex] page ${i + 1}: ${notes.join("; ")}`);
return pageToMarkdown(lines, L, layoutOf, [...figBlocks, ...cuts.blocks], cuts.drop).replace(/\n{3,}/g, "\n\n").trim();
});
const totalChars = pageMds.reduce((s, m) => s + m.length, 0);
const isScan = totalChars < n * 25;
const latex = latexCount(pageMds.join("\n"));
const md = buildDocument(src, pageMds, figures, isScan, layout.textWidth, latex, arxiv);
return { srcName: src.name, source: src.source, stem: safeStem(src.name), pages: n, figures, md, totalChars, isScan, textWidth: layout.textWidth, latex, arxiv };
} finally {
pdf.destroy();
}
}
// A conversion's folder under the bulb's, and the absolute path of a folder under it.
const folderOf = (stem: string) => `converted/${stem}`;
function absPath(rel: string): string {
const sep = tb.dir.includes("\\") ? "\\" : "/";
return tb.dir + sep + rel.split("/").join(sep);
}
// Writes the result under the bulb's folder as converted/<stem>/ and returns that folder's absolute path.
async function saveToProject(d: Conversion): Promise<string> {
const dir = folderOf(d.stem);
await tb.fs.write(`${dir}/document.md`, d.md);
for (const f of d.figures) await tb.fs.write(`${dir}/${f.file}`, f.png);
const abs = absPath(dir);
tb.log(`[convert] ${d.srcName}: ${d.pages} pages, ${d.figures.length} figures -> ${abs}/document.md`);
return abs;
}
// The folder's entries, none when it isn't there.
const listDir = async (path: string) => { try { return await tb.fs.list(path); } catch { return []; } };
// A conversion's title: the top heading on its first page (a paper's title is its largest text; a
// licence note can come first, smaller), else its folder's name.
function titleOf(md: string, stem: string): string {
const page = md.slice(Math.max(0, md.search(/^--- Page 1 ---$/m))).split(/^--- Page 2 ---$/m)[0];
for (const level of ["#", "##", "###"]) {
const h = page.match(new RegExp(`^${level} (.+)$`, "m"));
if (h) return unstyled(h[1]).replace(/\s+/g, " ").trim().slice(0, 100);
}
return stem;
}
// A saved conversion, as the picker lists it; mtime is the document's write time, the conversion's.
interface Saved { stem: string; title: string; mtime: number }
// The saved conversions, newest first by when their document was written: a re-conversion overwrites
// its files, which leaves the folder's own mtime where the first one put it.
async function listConversions(): Promise<Saved[]> {
const dirs = (await listDir("converted")).filter(e => e.dir);
const stamped = await Promise.all(dirs.map(async d => {
const doc = (await listDir(folderOf(d.name))).find(e => e.name === "document.md");
if (!doc) return null;
const md = await tb.fs.read(`${folderOf(d.name)}/document.md`);
return { stem: d.name, title: titleOf(md, d.name), mtime: doc.mtime };
}));
return stamped
.filter((s): s is Saved => s !== null)
.sort((a, b) => b.mtime - a.mtime);
}
// How long ago, the mirror's form: now, 5m, 3h, 2d, then "5 Jan" past a week.
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function relTime(ms: number): string {
const d = Math.max(0, Date.now() - ms);
if (d < 60_000) return "now";
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m`;
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h`;
if (d < 7 * 86_400_000) return `${Math.floor(d / 86_400_000)}d`;
const date = new Date(ms);
return `${date.getDate()} ${MONTHS[date.getMonth()]}`;
}
// The saved conversions as a search box: type to filter by title or stem, arrows and Enter (or a
// click) open one. The list is fixed-positioned, measured from the box: the bar clips its overflow
// for the scrollbar gutter, so an absolute list would scroll inside the bar instead of over the page.
function PrevPicker({ items, current, onOpen }: { items: Saved[]; current?: string; onOpen: (stem: string) => void }) {
const [filter, setFilter] = useState("");
const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(0);
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const q = filter.trim().toLowerCase();
const rows = q ? items.filter(s => s.title.toLowerCase().includes(q) || s.stem.toLowerCase().includes(q)) : items;
const place = () => {
const r = inputRef.current?.getBoundingClientRect();
if (r) setPos({ top: r.bottom + 6, left: r.left, width: r.width });
};
const show = () => { place(); setOpen(true); };
const close = () => { setOpen(false); setFilter(""); setHighlighted(0); };
// Outside click closes, armed a tick late so the click that opened the list isn't the one that closes it.
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => { if (!wrapRef.current?.contains(e.target as Node)) close(); };
const t = setTimeout(() => document.addEventListener("click", onClick));
window.addEventListener("resize", place);
return () => { clearTimeout(t); document.removeEventListener("click", onClick); window.removeEventListener("resize", place); };
}, [open]);
const move = (delta: number) => {
if (!rows.length) return;
const next = Math.max(0, Math.min(highlighted + delta, rows.length - 1));
setHighlighted(next);
setTimeout(() => (listRef.current?.children[next] as HTMLElement | undefined)?.scrollIntoView({ block: "nearest" }));
};
const activate = (i: number) => {
const s = rows[i];
if (!s) return;
close();
inputRef.current?.blur();
onOpen(s.stem);
};
const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") { close(); inputRef.current?.blur(); }
else if (e.key === "ArrowDown") { e.preventDefault(); open ? move(1) : show(); }
else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
else if (e.key === "Enter") { e.preventDefault(); activate(highlighted); }
};
return (
<div className="prev-picker" ref={wrapRef}>
<input
ref={inputRef}
className="url-input prev-input"
type="text"
placeholder={`Search ${items.length} saved conversion${items.length === 1 ? "" : "s"}…`}
aria-label="Search saved conversions"
value={filter}
onChange={(e) => { setFilter(e.target.value); setHighlighted(0); if (!open) show(); }}
onFocus={show}
onClick={() => { if (!open) show(); }}
onKeyDown={onKey}
/>
{filter && (
<button className="prev-clear" type="button" aria-label="Clear" onClick={() => { setFilter(""); setHighlighted(0); inputRef.current?.focus(); }}>×</button>
)}
{open && pos && (
<div className="prev-list" ref={listRef} style={pos}>
{rows.length === 0
? <div className="prev-empty">No match.</div>
: rows.map((s, i) => (
<div
key={s.stem}
className={`prev-row ${i === highlighted ? "active" : ""} ${s.stem === current ? "current" : ""}`}
onMouseEnter={() => setHighlighted(i)}
onClick={() => activate(i)}
>
<span className="prev-dot" />
<span className="prev-title">{s.title}</span>
{s.title !== s.stem && <span className="prev-stem">{s.stem}</span>}
<span className="prev-time">{relTime(s.mtime)}</span>
</div>
))}
</div>
)}
</div>
);
}
// A conversion read back from its folder: the document and its figure files, with the facts the
// details line states read off the document's header and page markers.
async function loadConversion(stem: string): Promise<Conversion> {
const dir = folderOf(stem);
const md = await tb.fs.read(`${dir}/document.md`);
const figures: FigureFile[] = [];
for (const e of await listDir(`${dir}/figures`)) {
if (e.dir || !/\.png$/i.test(e.name)) continue;
figures.push({ file: `figures/${e.name}`, png: await tb.fs.readBytes(`${dir}/figures/${e.name}`) });
}
const body = Math.max(0, md.search(/^--- Page \d+ ---$/m));
return {
srcName: md.match(/^# Markdown of (.+)$/m)?.[1] ?? `${stem}.pdf`,
source: md.match(/^Source: (.+)$/m)?.[1] ?? "",
stem,
pages: (md.match(/^--- Page \d+ ---$/gm) || []).length,
figures,
md,
totalChars: md.length - body,
isScan: md.includes("no text layer"),
textWidth: Number(md.match(/text column is (\d+) pt wide/)?.[1] ?? 0),
latex: latexCount(md),
arxiv: md.match(/LaTeX from arXiv:([^\s,]+)/)?.[1] ?? "",
};
}
// The same layout as the folder, zipped: what the result becomes where there is no folder.
function zipConversion(d: Conversion): Uint8Array {
const files: Zippable = { "document.md": strToU8(d.md) };
for (const f of d.figures) files[f.file] = [f.png, { level: 0 }]; // PNG is already deflated
return zipSync(files);
}
function downloadZip(d: Conversion) {
const blob = new Blob([zipConversion(d) as BlobPart], { type: "application/zip" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${d.stem}.zip`;
a.click();
URL.revokeObjectURL(url);
}
// A PDF to convert, and where it came from (the URL, the path, or for a dropped file its name).
interface Src { name: string; bytes: Uint8Array; source: string }
// Fetched by the page, so the host has to allow browser downloads (arXiv and most CDNs do). An
// arXiv link of any kind (abs, pdf, html, or a bare arXiv:id) fetches the paper's PDF.
async function fetchPdfUrl(url: string): Promise<Src> {
const id = arxivIdOf(url);
if (id) url = `https://arxiv.org/pdf/${id}`;
const seg = decodeURIComponent(new URL(url).pathname.split("/").filter(Boolean).pop() || "document");
const name = /\.pdf$/i.test(seg) ? seg : `${seg}.pdf`;
let r: Response;
try {
r = await fetch(url);
} catch {
throw new Error("That host doesn't allow browser downloads. Save the PDF and drop the file here instead.");
}
if (!r.ok) throw new Error(`The server answered HTTP ${r.status} for that URL.`);
return { name, bytes: new Uint8Array(await r.arrayBuffer()), source: url };
}
async function readPdfFile(path: string): Promise<Src> {
return { name: path.split(/[\\/]/).pop()!, bytes: await tb.fs.readBytes(path), source: path };
}
const withTrustHint = (e: any) => {
const msg = String(e?.message || e);
return /403|trust|forbidden/i.test(msg)
? "Not saved: this run isn't trusted. Restart as `npx typebulb typebulbs/u/samples/pdf-to-markdown.bulb.md --trust`, or `typebulb trust` it once."
: msg;
};
// Six-page synthetic PDF for the headless self-test (typebulb send <file> selftest).
async function makeTestPdf(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const bold = await doc.embedFont(StandardFonts.HelveticaBold);
const reg = await doc.embedFont(StandardFonts.Helvetica);
const p1 = doc.addPage([612, 792]);
p1.drawText("Quarterly Report", { x: 72, y: 730, size: 28, font: bold });
// An author block centered on the body column, its affiliation marks raised and small.
const body1 = "Revenue grew 14 percent over the prior quarter, driven by";
const colR = 72 + reg.widthOfTextAtSize(body1, 12);
const centeredRow = (parts: [string, number][], y: number) => {
let x = (72 + colR - parts.reduce((s, [t, size]) => s + reg.widthOfTextAtSize(t, size), 0)) / 2;
for (const [t, size] of parts) {
p1.drawText(t, { x, y: size < 12 ? y + 4 : y, size, font: reg });
x += reg.widthOfTextAtSize(t, size);
}
};
centeredRow([["Ada Lovelace", 12], ["1,2", 8]], 704);
centeredRow([["1", 8], ["Analytical Engine Society", 12]], 690);
p1.drawText(body1, { x: 72, y: 664, size: 12, font: reg });
p1.drawText("subscription renewals and two enterprise deals.", { x: 72, y: 648, size: 12, font: reg });
p1.drawText("Key risks", { x: 72, y: 610, size: 18, font: bold });
p1.drawText("• Churn in the SMB segment", { x: 72, y: 586, size: 12, font: reg });
p1.drawText("• Currency exposure in Europe", { x: 72, y: 570, size: 12, font: reg });
// A little line chart: axes plus a 24-segment polyline, so it reads as drawing, not a frame.
p1.drawLine({ start: { x: 90, y: 400 }, end: { x: 90, y: 540 }, thickness: 1 });
p1.drawLine({ start: { x: 90, y: 400 }, end: { x: 380, y: 400 }, thickness: 1 });
for (let i = 0; i < 24; i++) {
const x = 90 + i * 12, y = 420 + 50 * (1 + Math.sin(i / 3));
p1.drawLine({ start: { x, y }, end: { x: x + 12, y: 420 + 50 * (1 + Math.sin((i + 1) / 3)) }, thickness: 2, color: rgb(0.2, 0.4, 0.8) });
}
p1.drawText("Figure 1: A blue wave", { x: 72, y: 356, size: 12, font: reg });
// Margin decoration: a sideways stamp, and a page number.
p1.drawText("arXiv:0000.00000v1 [cs.LG] 1 Jan 2000", { x: 40, y: 300, size: 10, font: reg, rotate: degrees(90) });
p1.drawText("1", { x: 300, y: 40, size: 12, font: reg });
const p2 = doc.addPage([612, 792]);
// A heading wrapped at a hyphen, and a section title in body-sized capitals.
p2.drawText("Length Generaliza-", { x: 72, y: 720, size: 22, font: bold });
p2.drawText("tion Appendix", { x: 72, y: 694, size: 22, font: bold });
p2.drawText("Detailed tables", { x: 72, y: 660, size: 12, font: bold });
p2.drawText("follow in the attached spreadsheet.", { x: 72 + bold.widthOfTextAtSize("Detailed tables ", 12), y: 660, size: 12, font: reg });
p2.drawText("1 INTRODUCTION", { x: 72, y: 630, size: 12, font: reg });
p2.drawText("The appendix lists every table behind the report.", { x: 72, y: 610, size: 12, font: reg });
// A framed prompt with ragged lines, and a monospace listing: both keep their lines as code.
p2.drawRectangle({ x: 72, y: 500, width: 330, height: 60, borderWidth: 1, borderColor: rgb(0, 0, 0) });
p2.drawText("<answer>{", { x: 80, y: 543, size: 12, font: reg });
p2.drawText('"1": ["Kareem", "educated at", "UCLA"],', { x: 80, y: 527, size: 12, font: reg });
p2.drawText("}</answer>", { x: 80, y: 511, size: 12, font: reg });
const mono = await doc.embedFont(StandardFonts.Courier);
p2.drawText("def f(x):", { x: 72, y: 450, size: 12, font: mono });
p2.drawText("return x + 1", { x: 72 + mono.widthOfTextAtSize(" ", 12), y: 434, size: 12, font: mono });
// A subscript set low, past the line clustering's reach: it belongs to the line above it.
p2.drawText("Energy E", { x: 72, y: 400, size: 12, font: reg });
p2.drawText("k", { x: 72 + reg.widthOfTextAtSize("Energy E", 12), y: 395, size: 7, font: reg });
// A landscape page the LaTeX way: portrait with /Rotate 90, its text turned to read up the page,
// and a chart tall in page space (so wide as read); the page number stays upright.
const p3 = doc.addPage([612, 792]);
p3.setRotation(degrees(90));
p3.drawText("Sideways table", { x: 270, y: 100, size: 18, font: bold, rotate: degrees(90) });
p3.drawText("Sideways row one", { x: 300, y: 100, size: 12, font: reg, rotate: degrees(90) });
p3.drawText("Sideways row two", { x: 316, y: 100, size: 12, font: reg, rotate: degrees(90) });
for (let i = 0; i < 24; i++) {
const y = 140 + i * 12, x = 480 + 50 * (1 + Math.sin(i / 3));
p3.drawLine({ start: { x, y }, end: { x: 480 + 50 * (1 + Math.sin((i + 1) / 3)), y: y + 12 }, thickness: 2, color: rgb(0.2, 0.4, 0.8) });
}
p3.drawText("3", { x: 300, y: 40, size: 12, font: reg });
// A two-column page on the body column: a title across both columns, then five lines of prose in each.
const p4 = doc.addPage([612, 792]);
p4.drawText("Two Columns", { x: 152, y: 720, size: 22, font: bold });
for (const [side, x] of [["Left", 72], ["Right", 257]] as [string, number][])
["one", "two", "three", "four", "five"].forEach((w, i) =>
p4.drawText(`${side} column line ${w}`, { x, y: 680 - i * 16, size: 12, font: reg }));
// A contents page: chapter lines with no leader, sections with one, a title leaving room for
// two dots, one wrapped short of its number; then a page continuing the list mid-depth. The
// numbers are bold and set well past the leaders (pdf.js otherwise runs a leader and its number
// into one item), and inside the body column, so the layout measures the same as without them.
const entry = (pg: PDFPage, x: number, y: number, title: string, page: string, lead: boolean | number) => {
pg.drawText(title, { x, y, size: 12, font: reg });
if (lead) {
const start = x + reg.widthOfTextAtSize(title, 12) + 4;
const dots = typeof lead === "number" ? lead : Math.floor((280 - start) / reg.widthOfTextAtSize(". ", 12));
pg.drawText(". ".repeat(dots).trim(), { x: start, y, size: 12, font: reg });
}
if (page) pg.drawText(page, { x: 360, y, size: 12, font: bold });
};
const p5 = doc.addPage([612, 792]);
p5.drawText("Contents", { x: 72, y: 720, size: 22, font: bold });
entry(p5, 72, 680, "1 Overview", "4", false);
entry(p5, 92, 660, "1.1 Scope", "5", true);
entry(p5, 112, 640, "1.1.1 Terms", "5", true);
entry(p5, 92, 620, "1.2 Room for two dots", "6", 2);
entry(p5, 92, 600, "1.3 A title so long it wraps to the", "", false);
entry(p5, 92, 584, "next line", "6", true);
entry(p5, 72, 560, "2 Methods", "7", false);
const p6 = doc.addPage([612, 792]);
entry(p6, 92, 720, "2.1 Methods", "7", true);
entry(p6, 112, 700, "2.1.1 Data", "7", true);
entry(p6, 72, 680, "3 Results", "9", false);
return await doc.save();
}
const App = () => {
const [result, setResult] = useState<Conversion | null>(null);
const [html, setHtml] = useState("");
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState("");
const [error, setError] = useState("");
const [saved, setSaved] = useState<{ dir?: string; error?: string } | null>(null);
const [details, setDetails] = useState(false);
const [dragging, setDragging] = useState(false);
const [toast, setToast] = useState("");
const [url, setUrl] = useState("");
const [previous, setPrevious] = useState<Saved[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const resultRef = useRef<Conversion | null>(null);
const savedRef = useRef<{ dir?: string; error?: string } | null>(null);
const errorRef = useRef("");
const runRef = useRef<((src: Src) => Promise<Conversion | null>) | null>(null);
const flash = (m: string) => { setToast(m); window.setTimeout(() => setToast(""), 1800); };
const fail = (e: any) => {
console.error(e);
errorRef.current = withTrustHint(e) || "Failed to process PDF.";
setError(errorRef.current);
};
// A document is on its way: the busy pane shows, and nothing of the last one.
const begin = () => {
setBusy(true);
setError("");
setSaved(null);
savedRef.current = null;
setDetails(false);
setResult(null);
};
const run = async (src: Src): Promise<Conversion | null> => {
begin();
try {
const d = await processBytes(src, setProgress);
setResult(d);
resultRef.current = d;
if (tb.mode === "local") {
setProgress("Saving...");
try {
savedRef.current = { dir: await saveToProject(d) };
} catch (e: any) {
savedRef.current = { error: withTrustHint(e) };
}
setSaved(savedRef.current);
}
return d;
} catch (e: any) {
fail(e);
return null;
} finally {
setBusy(false);
setProgress("");
}
};
runRef.current = run;
// Opens a saved conversion in the preview, as if it had just been made.
const openPrevious = async (stem: string) => {
begin();
setProgress("Opening...");
try {
const d = await loadConversion(stem);
setResult(d);
resultRef.current = d;
savedRef.current = { dir: absPath(folderOf(stem)) };
setSaved(savedRef.current);
} catch (e: any) {
fail(e);
} finally {
setBusy(false);
setProgress("");
}
};
// The saved conversions, re-read as the page moves between documents: a save adds one, and a
// headless run may have while the page sat.
useEffect(() => {
if (tb.mode === "local") listConversions().then(setPrevious);
}, [result, saved]);
// The preview: the markdown rendered, its figure files served from memory, each sized to its
// share of the text column on the page (a one-line equation reads at the text's size, not the
// width of the pane). An equation with its LaTeX renders as KaTeX in the image's place, one
// display line per $$ block; a line KaTeX refuses (an author's macro) keeps the image, and so
// does every equation with Details on, where the crops are there to check against.
useEffect(() => {
if (!result) { setHtml(""); return; }
const imgs = new Map(result.figures.map(f => [f.file, {
url: URL.createObjectURL(new Blob([f.png as BlobPart], { type: "image/png" })),
pct: result.textWidth ? Math.min(100, pngDim(f.png).w / RENDER_SCALE / result.textWidth * 100) : 100,
}]));
const tex = new Map<string, string>();
const blocks = /^!\[[^\]]*\]\((figures\/[^)]+)\)\n\n((?:\$\$\n[\s\S]*?\n\$\$\n\n?)+)/gm;
let m: RegExpExecArray | null;
while ((m = blocks.exec(result.md))) {
const lines = m[2].split(/^\$\$$/m).map(s => s.trim()).filter(Boolean);
try { tex.set(m[1], lines.map(l => katex.renderToString(l, { displayMode: true, throwOnError: true })).join("")); } catch {}
}
// From the first page on: the header orients an agent reading the file, and Details has it here.
// The $$ blocks go too: marked would set the TeX as prose.
const body = result.md.slice(Math.max(0, result.md.search(/^--- Page \d+ ---$/m))).replace(/^\$\$\n[\s\S]*?\n\$\$\n/gm, "");
const out = (marked.parse(body, { async: false }) as string)
.replace(/<img src="(figures\/[^"]+)"([^>]*)>/g, (_, f, rest) => {
if (!details && tex.has(f)) return tex.get(f)!;
const i = imgs.get(f);
return `<img style="width:${(i?.pct ?? 100).toFixed(1)}%" src="${i?.url || ""}"${rest}>`;
})
.replace(/<p>--- Page (\d+) ---<\/p>/g, '<p class="pagemark">Page $1</p>');
setHtml(out);
return () => { for (const i of imgs.values()) URL.revokeObjectURL(i.url); };
}, [result, details]);
const handleFile = async (f?: File | null) => {
if (!f) return;
if (!/\.pdf$/i.test(f.name) && f.type !== "application/pdf") {
setError("That file doesn't look like a PDF.");
return;
}
await run({ name: f.name, bytes: new Uint8Array(await f.arrayBuffer()), source: f.name });
};
const onUrl = async () => {
const u = url.trim();
if (!u) return;
setBusy(true);
setError("");
setProgress("Fetching...");
let src: Src;
try {
src = await fetchPdfUrl(u);
} catch (e: any) {
fail(e);
setBusy(false);
setProgress("");
return;
}
await run(src);
};
const reset = () => { setResult(null); resultRef.current = null; setSaved(null); setDetails(false); setError(""); };
// The site's link box takes arXiv links only: arXiv allows a browser download and most hosts
// don't, and a box that fails on most links reads as broken. The CLI's box takes any link.
const arxivOnly = tb.mode !== "local";
const badLink = arxivOnly && url.trim() !== "" && !arxivIdOf(url);
// The saved conversions as a search box: on the landing pane, and beside the open document to switch.
const prevPicker = previous.length > 0 && <PrevPicker items={previous} current={result?.stem} onOpen={openPrevious} />;
// Headless: {url} or {file} (a path inside the bulb's folder) converts and saves, replying with where;
// selftest runs a synthetic PDF through. See notes.md.
useEffect(() => {
tb.onMessage(async (m: any) => {
try {
if (m && typeof m === "object" && (m.url || m.file)) {
const src = m.url ? await fetchPdfUrl(m.url) : await readPdfFile(m.file);
const d = await runRef.current!(src);
if (!d) return { error: errorRef.current };
return { ...savedRef.current, pages: d.pages, chars: d.totalChars, figures: d.figures.length, latex: d.latex, isScan: d.isScan };
}
if (m === "selftest") {
const d = await runRef.current!({ name: "selftest.pdf", bytes: await makeTestPdf(), source: "selftest.pdf" });
if (!d) return { error: "processing failed" };
// The turned page's figure, read the page's way, is wider than tall (PNG header: width at 16, height at 20).
const side = d.figures.find(f => f.file.includes("page-003-fig"));
return {
...savedRef.current,
pages: d.pages,
mdChars: d.md.length,
markers: (d.md.match(/^--- Page \d+ ---$/gm) || []).length,
figures: d.figures.length,
figRef: (d.md.match(/!\[[^\]]*\]\(figures\/[^)]+\.png\)/g) || []).join(" "),
fig0Bytes: d.figures[0]?.png.length ?? 0,
zipBytes: zipConversion(d).length,
isScan: d.isScan,
arxiv: d.arxiv,
latex: d.latex,
superscript: d.md.includes("Lovelace¹,²"),
affiliationBreak: d.md.includes("Lovelace¹,²\\\n¹Analytical"),
wrappedHeading: d.md.includes("# Length Generalization Appendix"),
capsHeading: d.md.includes("## 1 INTRODUCTION"),
stampDropped: !d.md.includes("arXiv:"),
pageNumberDropped: !/^1$/m.test(d.md),
bold: d.md.includes("**Detailed tables** follow"),
fencedBox: d.md.includes("```\n<answer>{\n\"1\": [\"Kareem\""),
monoListing: d.md.includes("def f(x):\n return x + 1"),
lowSubscript: d.md.includes("Energy Eₖ"),
sideways: d.md.includes("## Sideways table\n\nSideways row one Sideways row two"),
sidewaysFigWide: !!side && pngDim(side.png).w > pngDim(side.png).h,
columnWidth: d.textWidth,
// Raw HTML in the text renders as text, once escaped, in prose and in code alike.
escapes: marked.parse("<b>\n\n`<c>`", { async: false }) as string,
twoColumn: d.md.includes("# Two Columns\n\nLeft column line one Left column line two Left column line three Left column line four Left column line five\n\nRight column line one Right column line two"),
contents: d.md.includes("# Contents\n\n- 1 Overview … 4\n - 1.1 Scope … 5\n - 1.1.1 Terms … 5\n - 1.2 Room for two dots … 6\n - 1.3 A title so long it wraps to the next line … 6\n- 2 Methods … 7\n"),
contentsContinued: d.md.includes("- 2.1 Methods … 7\n - 2.1.1 Data … 7\n- 3 Results … 9\n"),
mdHead: d.md.slice(0, 900),
};
}
} catch (e: any) {
return { error: String(e?.message || e) };
}
});
}, []);
return (
<div
className={`app ${dragging ? "dragging" : ""}`}
onDragEnter={(e) => { e.preventDefault(); setDragging(true); }}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={(e) => {
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
setDragging(false);
}}
onDrop={(e) => { e.preventDefault(); setDragging(false); handleFile(e.dataTransfer.files?.[0]); }}
>
{result && !busy && (
<header className="bar">
<div className="bar-actions">
{tb.mode === "local" ? saved?.dir && (
<button className="secondary-btn" onClick={() => { tb.copy(saved.dir!); flash("Path copied"); }}>
Copy folder path
</button>
) : (
<button className="secondary-btn" onClick={() => downloadZip(result)}>Download zip</button>
)}
<button className={`secondary-btn ${details ? "active" : ""}`} onClick={() => setDetails(!details)}>
Details
</button>
<button className="secondary-btn" onClick={reset}>Another PDF</button>
{prevPicker}
</div>
</header>
)}
<input
ref={fileInputRef}
type="file"
accept="application/pdf,.pdf"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
e.target.value = "";
}}
/>
{result && !busy && saved?.error && <div className="panel err">{saved.error}</div>}
{result && !busy && details && (
<div className="panel hint">
<span>
{result.pages} {result.pages === 1 ? "page" : "pages"} · {(result.totalChars / 1000).toFixed(1)}k characters of text ·{" "}
{result.figures.filter(f => !isEquationFile(f)).length} figures · {result.figures.filter(isEquationFile).length} equations
{result.latex > 0 && ` · ${result.latex} with LaTeX`}
{result.isScan && <span className="scan-note"> · no text layer found (a scan): each page is a figure</span>}
</span>
{result.source && (
<span>Source: {/^https?:/.test(result.source)
? <a href={result.source} target="_blank" rel="noreferrer">{result.source}</a>
: result.source}</span>
)}
{saved?.dir && <span>Saved to {saved.dir}</span>}
</div>
)}
{!result && !busy && !error && (
<div className="state-pane">
<div className="dropzone">
<h1 className="splash-title">PDF To Markdown</h1>
<div className="dropzone-actions">
<button className="primary-btn" onClick={() => fileInputRef.current?.click()}>Choose or drop a PDF</button>
<form className="url-form" onSubmit={(e) => { e.preventDefault(); onUrl(); }}>
<input
className="url-input"
type="url"
placeholder={arxivOnly ? "or paste an arXiv link" : "or paste a link"}
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<button className="secondary-btn" type="submit" disabled={!url.trim() || badLink}>Fetch</button>
</form>
{badLink && <p className="dropzone-note">Only arXiv links work here (arxiv.org/abs/… or /pdf/…). For another PDF, drop the file.</p>}
{prevPicker}
</div>
<p className="dropzone-sub">
Creates a markdown file with extracted images and LaTeX{tb.mode === "local" ? "" : ", downloadable as a zip. Everything runs in your browser." }
</p>
{arxivOnly && <p className="dropzone-tip">For local Claude/Codex integration, tell your agent: run "npx typebulb agent", then pull this bulb.</p>}
</div>
</div>
)}
{busy && (
<div className="state-pane">
<div className="dropzone">
<p className="dropzone-title">{progress || "Working..."}</p>
</div>
</div>
)}
{error && !busy && (
<div className="state-pane">
<div className="dropzone error">
<p className="dropzone-title">Conversion failed</p>
<p className="dropzone-sub">{error}</p>
<button className="secondary-btn retry" onClick={() => setError("")}>Try again</button>
</div>
</div>
)}
{result && !busy && <div className="mdview" dangerouslySetInnerHTML={{ __html: html }} />}
{dragging && <div className="drag-overlay">Drop PDF to convert</div>}
<div className={`toast ${toast ? "show" : ""}`}>{toast}</div>
</div>
);
};
const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);
```
**styles.css**
```css
:root {
--bg: #fafafa;
--pane-bg: #efeff1;
--text: #1d1d1f;
--text-muted: #6e6e73;
--border: rgba(0, 0, 0, 0.1);
--card-bg: #ffffff;
--selection: rgba(0, 0, 0, 0.05);
--accent: #1d1d1f;
--ok: #1a7f37;
--err: #d04545;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
}
html[data-theme="dark"] {
--bg: #1c1c1c;
--pane-bg: #1c1c1c;
--text: #f5f5f7;
--text-muted: #a1a1a6;
--border: rgba(255, 255, 255, 0.1);
--card-bg: #232323;
--selection: rgba(255, 255, 255, 0.08);
--accent: #f5f5f7;
--ok: #4ac26b;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6);
}
* { box-sizing: border-box; }
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app {
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}
/* The bar, the panels, and the document share one column, so their left edges line up: same
side padding, same centered 800px column, and the scrollbar gutter reserved on all of them. */
.bar, .panel, .mdview {
padding-left: 2rem;
padding-right: 2rem;
overflow: auto;
scrollbar-gutter: stable both-edges;
}
.bar > *, .panel > *, .mdview > * { max-width: 800px; margin-left: auto; margin-right: auto; }
.bar {
padding-top: 0.875rem;
padding-bottom: 0.875rem;
flex-shrink: 0;
}
.bar-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.primary-btn {
background: var(--accent);
color: var(--card-bg);
border: 1px solid var(--accent);
padding: 0.5rem 1.1rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: opacity 0.2s;
letter-spacing: -0.01em;
}
.primary-btn:hover { opacity: 0.9; }
.primary-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.secondary-btn {
background: var(--card-bg);
color: var(--text);
border: 1px solid var(--border);
padding: 0.5rem 1rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: box-shadow 0.2s;
letter-spacing: -0.01em;
}
.secondary-btn:hover { box-shadow: var(--shadow-sm); }
.secondary-btn.small { padding: 0.3rem 0.75rem; font-size: 0.8125rem; }
.secondary-btn.active { box-shadow: inset 0 0 0 1px var(--accent); }
.panel {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding-top: 0.6rem;
padding-bottom: 0.6rem;
font-size: 0.875rem;
flex-shrink: 0;
}
.hint { color: var(--text-muted); }
.panel a { color: inherit; }
.panel.err { color: var(--err); }
.scan-note { color: var(--err); }
.state-pane {
flex: 1;
background: var(--pane-bg);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.dropzone {
background: var(--card-bg);
border: 1px solid var(--border);
padding: 3.75rem 3.25rem;
max-width: 508px;
text-align: center;
box-shadow: var(--shadow-md);
}
.dropzone.error { border-color: var(--err); }
.splash-title {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 1.25rem;
letter-spacing: -0.02em;
}
.dropzone-title {
font-size: 1.0625rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
color: var(--text);
}
.dropzone-sub {
margin: 0;
font-size: 0.875rem;
line-height: 1.55;
color: var(--text-muted);
}
.dropzone-actions {
display: grid;
gap: 0.6rem;
justify-items: center;
margin: 0.25rem 0 1rem;
}
.dropzone-actions .primary-btn {
width: 100%;
padding: 0.8rem 1.2rem;
font-size: 1rem;
border-radius: 10px;
}
.url-form {
display: flex;
gap: 0.4rem;
width: 100%;
}
.url-input, .prev-input {
flex: 1;
min-width: 0;
font: inherit;
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card-bg);
color: var(--text);
}
/* The saved-conversions search box and its list: the mirror's picker (filter box over a keyboard-
navigable list) opening downward, sized to the box. The list is position: fixed, see PrevPicker. */
.prev-picker { position: relative; flex: 1 1 16rem; max-width: 28rem; }
.dropzone-actions .prev-picker { width: 100%; max-width: none; }
.prev-input { width: 100%; padding-right: 2rem; }
.prev-clear {
position: absolute; right: 0.4rem; top: 50%; transform: translateY(-50%);
appearance: none; border: none; background: transparent; cursor: pointer;
color: var(--text-muted); font-size: 1.1rem; line-height: 1; padding: 0 0.3rem; border-radius: 4px;
}
.prev-clear:hover { color: var(--text); }
.prev-list {
position: fixed; z-index: 10;
display: flex; flex-direction: column; gap: 0.15rem;
max-height: 420px; overflow-y: auto;
padding: 0.35rem;
text-align: left;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow-lg);
}
.prev-row {
display: flex; flex: none; gap: 0.6rem; align-items: baseline;
padding: 0.4rem 0.6rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
/* The keyboard cursor; hover moves it, so one tint serves both. */
.prev-row.active { background: color-mix(in srgb, var(--accent) 12%, transparent); }
/* The open document's row: an accent dot in a gutter reserved on every row, so titles align. */
.prev-dot { flex: none; align-self: center; width: 7px; height: 7px; border-radius: 50%; background: transparent; }
.prev-row.current .prev-dot { background: var(--accent); }
.prev-title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.prev-stem { color: var(--text-muted); max-width: 45%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.prev-time { color: var(--text-muted); flex: none; min-width: 2.6rem; text-align: right; }
.prev-empty { padding: 0.8rem; color: var(--text-muted); text-align: center; }
.dropzone-note { margin: 0; font-size: 0.875rem; line-height: 1.55; }
/* The local-run tip under the card's blurb, set off by the theme's own tint rather than a colour of its own. */
.dropzone-tip {
margin: 1rem 0 0;
padding: 0.6rem 0.9rem;
border-radius: 8px;
background: var(--selection);
font-size: 0.875rem;
line-height: 1.55;
}
.secondary-btn.retry { margin-top: 1rem; }
.mdview {
flex: 1;
min-height: 0;
padding-top: 1.5rem;
padding-bottom: 4rem;
font-size: 0.9375rem;
line-height: 1.6;
}
.mdview h1 { font-size: 1.5rem; margin-top: 1.5rem; margin-bottom: 0.5rem; letter-spacing: -0.02em; }
.mdview h2 { font-size: 1.25rem; margin-top: 1.5rem; margin-bottom: 0.4rem; }
.mdview h3 { font-size: 1.0625rem; margin-top: 1.25rem; margin-bottom: 0.3rem; }
.mdview p { margin-top: 0; margin-bottom: 0.9rem; }
.mdview ul, .mdview ol { margin-top: 0; margin-bottom: 0.9rem; padding-left: 1.5rem; }
.mdview .pagemark {
color: var(--text-muted);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-top: 2rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.mdview .pagemark:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
.mdview img {
display: block;
max-width: 100%;
height: auto;
border: 1px solid var(--border);
border-radius: 6px;
margin: 0.25rem 0 0.5rem;
}
/* Wide math scrolls sideways; auto on one axis alone would put a vertical bar on every block, for
the few pixels the line box overhangs. */
.mdview .katex-display {
margin: 0.25rem 0 0.9rem;
padding: 0.35rem 0;
overflow: auto hidden;
}
.mdview table {
display: block;
overflow-x: auto;
border-collapse: collapse;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.mdview th, .mdview td {
border: 1px solid var(--border);
padding: 0.35rem 0.6rem;
text-align: left;
vertical-align: top;
}
.mdview th { background: var(--selection); }
.mdview code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875em;
}
.mdview :not(pre) > code {
background: var(--selection);
padding: 0.1em 0.35em;
border-radius: 4px;
}
.mdview pre {
background: var(--selection);
padding: 0.75rem 1rem;
border-radius: 8px;
overflow-x: auto;
margin: 0 auto 1rem;
}
.drag-overlay {
position: absolute;
inset: 0;
background: rgba(29, 29, 31, 0.85);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
pointer-events: none;
z-index: 100;
border: 3px dashed rgba(255, 255, 255, 0.6);
}
.toast {
position: fixed;
left: 50%;
bottom: 22px;
transform: translate(-50%, 12px);
background: var(--text);
color: var(--bg);
padding: 8px 16px;
border-radius: 999px;
font-size: 0.8125rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.18s, transform 0.18s;
z-index: 200;
}
.toast.show { opacity: 0.92; transform: translate(-50%, 0); }
@media (max-width: 800px) {
.bar, .panel, .mdview { padding-left: 1rem; padding-right: 1rem; }
.bar { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.mdview { padding-top: 1rem; padding-bottom: 3rem; }
}
```
**index.html**
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css">
<div id="root"></div>
```
**config.json**
```json
{
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3",
"pdfjs-dist": "^5.4.530",
"pdf-lib": "^1.17.1",
"marked": "^15.0.0",
"fflate": "^0.8.3",
"katex": "0.16.47"
},
"description": "Convert a PDF into a markdown document, handling images and latex."
}
```
**notes.md**
```md
Agent recipe: convert a PDF headlessly, then read the result.
1. Run the bulb trusted (it writes the files): `npx typebulb typebulbs/u/samples/pdf-to-markdown.bulb.md --trust`, backgrounded.
2. Send it the PDF and wait for the reply:
`echo '{"url":"https://arxiv.org/pdf/2603.09970"}' | typebulb send typebulbs/u/samples/pdf-to-markdown.bulb.md - --wait=300000`
or `{"file":"x.pdf"}`: a file inside the bulb's folder (`typebulbs/u/samples/pdf-to-markdown/`), relative to it or absolute; nothing outside it can be read.
The reply is `{"dir": "<folder>", "pages", "chars", "figures"}`; a long PDF takes a minute or two, so size `--wait` to it.
3. Read `<folder>/document.md`. Its figures are PNGs under `<folder>/figures/`, referenced where they appear; open one when a chart, diagram, or screenshot matters. An arXiv paper's display equations carry their LaTeX in `$$` blocks under the image, so those need no opening.
The URL is fetched by the page, so the host must allow browser downloads (arXiv and most CDNs do); for one that doesn't, download the file into the bulb's folder and send `file`. The result always lands in `typebulbs/u/samples/pdf-to-markdown/converted/<stem>/`. `typebulb send <bulb> selftest --wait=60000` runs a synthetic PDF through the pipeline and reports what it found.
When a page converts badly, the run log (`typebulb logs <bulb>`) says what the pipeline decided: `[body]` the body font size and the pages measuring their own, `[columns]` the two-column pages and the gutter, `[frame]` the pages read turned, `[eq]` each page's text cut as display equations, `[fig]` each ink region's fate (figure, equation, text, or stray) with its box, drawn-element count, drawing weight, text density, the share of its text that is flow text, whether a caption claimed it, and what it took in, then the rules cleared as page furniture, `[arxiv]` whether the paper's HTML rendering was found, and `[latex]` each numbered equation's agreement with its LaTeX, rejections named.
```